feat(feedback): add problem reporting flow
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
export const FEEDBACK_CATEGORIES = [
|
||||
"problem",
|
||||
"suggestion",
|
||||
"payment",
|
||||
"other",
|
||||
] as const;
|
||||
|
||||
export type FeedbackCategory = (typeof FEEDBACK_CATEGORIES)[number];
|
||||
|
||||
export interface FeedbackContext {
|
||||
appVersion: string;
|
||||
platform: string;
|
||||
browser: string;
|
||||
viewport: string;
|
||||
}
|
||||
|
||||
export interface SubmitFeedbackInput {
|
||||
category: FeedbackCategory;
|
||||
content: string;
|
||||
context: FeedbackContext;
|
||||
images: readonly File[];
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./feedback_submission";
|
||||
export * from "./response";
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
FeedbackSubmitResponseSchema,
|
||||
type FeedbackSubmitResponseData,
|
||||
type FeedbackSubmitResponseInput,
|
||||
} from "@/data/schemas/feedback";
|
||||
|
||||
export class FeedbackSubmitResponse {
|
||||
declare readonly feedbackId: string;
|
||||
|
||||
private constructor(input: FeedbackSubmitResponseInput) {
|
||||
Object.assign(this, FeedbackSubmitResponseSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FeedbackSubmitResponseInput): FeedbackSubmitResponse {
|
||||
return new FeedbackSubmitResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FeedbackSubmitResponse {
|
||||
return FeedbackSubmitResponse.from(json as FeedbackSubmitResponseInput);
|
||||
}
|
||||
|
||||
toJson(): FeedbackSubmitResponseData {
|
||||
return FeedbackSubmitResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./feedback_submit_response";
|
||||
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
FeedbackSubmitResponse,
|
||||
SubmitFeedbackInput,
|
||||
} from "@/data/dto/feedback";
|
||||
import type { IFeedbackRepository } from "@/data/repositories/interfaces";
|
||||
import { FeedbackApi, feedbackApi } from "@/data/services/api/feedback_api";
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
import { createLazySingleton } from "./lazy_singleton";
|
||||
|
||||
export class FeedbackRepository implements IFeedbackRepository {
|
||||
constructor(private readonly api: FeedbackApi) {}
|
||||
|
||||
submit(input: SubmitFeedbackInput): Promise<Result<FeedbackSubmitResponse>> {
|
||||
return Result.wrap(() => this.api.submit(input));
|
||||
}
|
||||
}
|
||||
|
||||
export const getFeedbackRepository = createLazySingleton<IFeedbackRepository>(
|
||||
() => new FeedbackRepository(feedbackApi),
|
||||
);
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
export * from "./auth_repository";
|
||||
export * from "./chat_repository";
|
||||
export * from "./feedback_repository";
|
||||
export * from "./metrics_repository";
|
||||
export * from "./payment_repository";
|
||||
export * from "./private_room_repository";
|
||||
export * from "./user_repository";
|
||||
export * from "./interfaces/iauth_repository";
|
||||
export * from "./interfaces/ichat_repository";
|
||||
export * from "./interfaces/ifeedback_repository";
|
||||
export * from "./interfaces/imetrics_repository";
|
||||
export * from "./interfaces/ipayment_repository";
|
||||
export * from "./interfaces/iprivate_room_repository";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type {
|
||||
FeedbackSubmitResponse,
|
||||
SubmitFeedbackInput,
|
||||
} from "@/data/dto/feedback";
|
||||
import type { Result } from "@/utils/result";
|
||||
|
||||
export interface IFeedbackRepository {
|
||||
submit(input: SubmitFeedbackInput): Promise<Result<FeedbackSubmitResponse>>;
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
export * from "./iauth_repository";
|
||||
export * from "./ichat_repository";
|
||||
export * from "./ifeedback_repository";
|
||||
export * from "./imetrics_repository";
|
||||
export * from "./ipayment_repository";
|
||||
export * from "./iprivate_room_repository";
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./response";
|
||||
@@ -0,0 +1,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const FeedbackSubmitResponseSchema = z.object({
|
||||
feedbackId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type FeedbackSubmitResponseInput = z.input<
|
||||
typeof FeedbackSubmitResponseSchema
|
||||
>;
|
||||
export type FeedbackSubmitResponseData = z.output<
|
||||
typeof FeedbackSubmitResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./feedback_submit_response";
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const httpClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../http_client", () => ({
|
||||
httpClient: httpClientMock,
|
||||
}));
|
||||
|
||||
import { FeedbackApi } from "../feedback_api";
|
||||
|
||||
describe("FeedbackApi", () => {
|
||||
beforeEach(() => {
|
||||
httpClientMock.mockReset();
|
||||
});
|
||||
|
||||
it("submits the supported fields as multipart data", async () => {
|
||||
httpClientMock.mockResolvedValue({
|
||||
success: true,
|
||||
data: { feedbackId: "feedback-123", ignored: "server-only" },
|
||||
});
|
||||
const image = new File(["image"], "screen.jpg", {
|
||||
type: "image/jpeg",
|
||||
});
|
||||
|
||||
const response = await new FeedbackApi().submit({
|
||||
category: "problem",
|
||||
content: "The send button did not respond.",
|
||||
context: {
|
||||
appVersion: "0.1.0",
|
||||
platform: "android Android 15",
|
||||
browser: "facebook IAB / Chrome 135",
|
||||
viewport: "392x760@2.75",
|
||||
},
|
||||
images: [image],
|
||||
});
|
||||
|
||||
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" });
|
||||
expect(httpClientMock).toHaveBeenCalledWith(
|
||||
"/api/feedback",
|
||||
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
|
||||
);
|
||||
const body = httpClientMock.mock.calls[0][1].body as FormData;
|
||||
expect(body.get("category")).toBe("problem");
|
||||
expect(body.get("content")).toBe("The send button did not respond.");
|
||||
expect(JSON.parse(String(body.get("context")))).toEqual({
|
||||
appVersion: "0.1.0",
|
||||
platform: "android Android 15",
|
||||
browser: "facebook IAB / Chrome 135",
|
||||
viewport: "392x760@2.75",
|
||||
});
|
||||
expect(body.getAll("images")).toEqual([image]);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,13 @@ function callUrl(call: Parameters<typeof fetch>): URL {
|
||||
return new URL(request instanceof Request ? request.url : String(request));
|
||||
}
|
||||
|
||||
function callHeaders(call: Parameters<typeof fetch>): Headers {
|
||||
const [request, init] = call;
|
||||
return request instanceof Request
|
||||
? request.headers
|
||||
: new Headers(init?.headers);
|
||||
}
|
||||
|
||||
describe("createHttpClient", () => {
|
||||
beforeEach(() => {
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test";
|
||||
@@ -264,4 +271,35 @@ describe("createHttpClient", () => {
|
||||
]);
|
||||
await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null));
|
||||
});
|
||||
|
||||
it("lets ofetch set JSON content headers for object bodies", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({ success: true }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const http = createHttpClient();
|
||||
await http("/api/example", {
|
||||
method: "POST",
|
||||
body: { message: "hello" },
|
||||
});
|
||||
|
||||
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toContain(
|
||||
"application/json",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override multipart boundaries for FormData bodies", async () => {
|
||||
const fetchMock = vi.fn<typeof fetch>(async () =>
|
||||
jsonResponse({ success: true }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const body = new FormData();
|
||||
body.append("content", "feedback");
|
||||
|
||||
const http = createHttpClient();
|
||||
await http("/api/feedback", { method: "POST", body });
|
||||
|
||||
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,4 +103,7 @@ export class ApiPath {
|
||||
|
||||
/** 上报用户信息 */
|
||||
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
|
||||
|
||||
// ============ 用户反馈相关 ============
|
||||
static readonly feedback = `${ApiPath._baseUrl}/feedback`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
FeedbackSubmitResponse,
|
||||
type SubmitFeedbackInput,
|
||||
} from "@/data/dto/feedback";
|
||||
|
||||
import { ApiPath } from "./api_path";
|
||||
import { httpClient } from "./http_client";
|
||||
import { type ApiEnvelope, unwrap } from "./response_helper";
|
||||
|
||||
export class FeedbackApi {
|
||||
async submit(input: SubmitFeedbackInput): Promise<FeedbackSubmitResponse> {
|
||||
const body = new FormData();
|
||||
body.append("category", input.category);
|
||||
body.append("content", input.content);
|
||||
body.append("context", JSON.stringify(input.context));
|
||||
input.images.forEach((image) => body.append("images", image));
|
||||
|
||||
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.feedback, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
return FeedbackSubmitResponse.fromJson(unwrap(envelope));
|
||||
}
|
||||
}
|
||||
|
||||
export const feedbackApi = new FeedbackApi();
|
||||
@@ -44,9 +44,6 @@ export function createHttpClient(): $Fetch {
|
||||
timeout: config.receiveTimeout,
|
||||
retry: 1, // 401 刷新后由 ofetch 重放一次原请求
|
||||
retryStatusCodes: [ErrorCode.httpUnauthorized],
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
onRequest: chain(
|
||||
loggingInterceptor.onRequest,
|
||||
tokenInterceptor.onRequest
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from "./api_path";
|
||||
export * from "./api_result";
|
||||
export * from "./auth_api";
|
||||
export * from "./chat_api";
|
||||
export * from "./feedback_api";
|
||||
export * from "./http_client";
|
||||
export * from "./metrics_api";
|
||||
export * from "./payment_api";
|
||||
|
||||
Reference in New Issue
Block a user