refactor(data): merge DTO models into schemas
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../schema_model";
|
||||
|
||||
const ExampleSchema = z.object({
|
||||
id: z.string(),
|
||||
traceId: z.string().default("generated-trace"),
|
||||
});
|
||||
|
||||
type ExampleModel = SchemaModel<typeof ExampleSchema, "id">;
|
||||
const ExampleModel = createSchemaModel<typeof ExampleSchema, "id">(
|
||||
ExampleSchema,
|
||||
);
|
||||
|
||||
describe("createSchemaModel", () => {
|
||||
it("parses, freezes and serializes through the source schema", () => {
|
||||
const model: ExampleModel = ExampleModel.from({ id: "item-1" });
|
||||
|
||||
expect(model).toBeInstanceOf(ExampleModel);
|
||||
expect(Object.isFrozen(model)).toBe(true);
|
||||
expect(model.id).toBe("item-1");
|
||||
expect(model.toJson()).toEqual({
|
||||
id: "item-1",
|
||||
traceId: "generated-trace",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-public schema fields out of the declared model interface", () => {
|
||||
const model = ExampleModel.fromJson({ id: "item-1", traceId: "trace-1" });
|
||||
|
||||
// @ts-expect-error traceId is serialized but not part of the public API.
|
||||
expect(model.traceId).toBe("trace-1");
|
||||
});
|
||||
|
||||
it("preserves schema validation errors", () => {
|
||||
expect(() => ExampleModel.fromJson({ id: 1 })).toThrow(z.ZodError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
FacebookIdentityResponse,
|
||||
FacebookPsidLoginResponse,
|
||||
LoginRequest,
|
||||
} from "@/data/schemas/auth";
|
||||
import { UserSchema } from "@/data/schemas/user/user";
|
||||
|
||||
describe("facebook identity schema models", () => {
|
||||
it("parses facebook identity binding responses", () => {
|
||||
const response = FacebookIdentityResponse.fromJson({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
facebookBinding: {
|
||||
conflicts: [],
|
||||
bound: [{ field: "psid" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.toJson()).toMatchObject({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
facebookBinding: {
|
||||
conflicts: [],
|
||||
bound: [{ field: "psid" }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("parses facebook psid login responses for real users and guests", () => {
|
||||
expect(
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
token: "login-token",
|
||||
refreshToken: "refresh-token",
|
||||
matchedBy: "psid",
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
hasCompleteFacebookIdentity: true,
|
||||
isGuest: false,
|
||||
user: { id: "user-1", username: "Elio" },
|
||||
}).toJson(),
|
||||
).toMatchObject({
|
||||
isGuest: false,
|
||||
hasCompleteFacebookIdentity: true,
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
expect(
|
||||
FacebookPsidLoginResponse.fromJson({
|
||||
token: "guest-token",
|
||||
matchedBy: "guest",
|
||||
fbPsid: "psid-1",
|
||||
hasCompleteFacebookIdentity: false,
|
||||
isGuest: true,
|
||||
userId: "guest-1",
|
||||
}).toJson(),
|
||||
).toMatchObject({
|
||||
refreshToken: "",
|
||||
isGuest: true,
|
||||
userId: "guest-1",
|
||||
user: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps psid in request serialization without a public field declaration", () => {
|
||||
const request = LoginRequest.from({
|
||||
email: "user@example.com",
|
||||
password: "password",
|
||||
platform: "web",
|
||||
guestId: "",
|
||||
psid: "psid-1",
|
||||
});
|
||||
|
||||
expect(request.toJson()).toMatchObject({ psid: "psid-1" });
|
||||
});
|
||||
|
||||
it("accepts facebook identity fields in user schema", () => {
|
||||
expect(
|
||||
UserSchema.parse({
|
||||
id: "user-1",
|
||||
username: "Elio",
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
}),
|
||||
).toMatchObject({
|
||||
fbAsid: "asid-1",
|
||||
fbPsid: "psid-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,3 +17,28 @@ export const FacebookUserDataSchema = z.object({
|
||||
|
||||
export type FacebookUserDataInput = z.input<typeof FacebookUserDataSchema>;
|
||||
export type FacebookUserDataData = z.output<typeof FacebookUserDataSchema>;
|
||||
|
||||
export class FacebookUserData {
|
||||
declare readonly id: string | null;
|
||||
declare readonly name: string | null;
|
||||
declare readonly email: string | null;
|
||||
declare readonly pictureUrl: string | null;
|
||||
|
||||
private constructor(input: FacebookUserDataInput) {
|
||||
const data = FacebookUserDataSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FacebookUserDataInput): FacebookUserData {
|
||||
return new FacebookUserData(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookUserData {
|
||||
return FacebookUserData.from(json as FacebookUserDataInput);
|
||||
}
|
||||
|
||||
toJson(): FacebookUserDataData {
|
||||
return FacebookUserDataSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
|
||||
export * from "./facebook_user_data";
|
||||
export * from "./login_status";
|
||||
export * from "./request/facebook_identity_request";
|
||||
export * from "./request/facebook_login_request";
|
||||
export * from "./request/facebook_psid_login_request";
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Login status persisted by the auth data layer. */
|
||||
export const LoginStatus = {
|
||||
NotLoggedIn: "notLoggedIn",
|
||||
Guest: "guest",
|
||||
Facebook: "facebook",
|
||||
Google: "google",
|
||||
Email: "email",
|
||||
} as const;
|
||||
|
||||
export type LoginStatus = (typeof LoginStatus)[keyof typeof LoginStatus];
|
||||
@@ -3,6 +3,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookIdentityRequestSchema = z.object({
|
||||
@@ -16,3 +21,10 @@ export type FacebookIdentityRequestInput = z.input<
|
||||
export type FacebookIdentityRequestData = z.output<
|
||||
typeof FacebookIdentityRequestSchema
|
||||
>;
|
||||
|
||||
export type FacebookIdentityRequest = SchemaModel<
|
||||
typeof FacebookIdentityRequestSchema
|
||||
>;
|
||||
export const FacebookIdentityRequest = createSchemaModel(
|
||||
FacebookIdentityRequestSchema,
|
||||
);
|
||||
|
||||
@@ -16,3 +16,28 @@ export const FacebookLoginRequestSchema = z.object({
|
||||
|
||||
export type FacebookLoginRequestInput = z.input<typeof FacebookLoginRequestSchema>;
|
||||
export type FacebookLoginRequestData = z.output<typeof FacebookLoginRequestSchema>;
|
||||
|
||||
export class FacebookLoginRequest {
|
||||
declare readonly accessToken: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: FacebookLoginRequestInput) {
|
||||
const data = FacebookLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FacebookLoginRequestInput): FacebookLoginRequest {
|
||||
return new FacebookLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookLoginRequest {
|
||||
return FacebookLoginRequest.from(json as FacebookLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): FacebookLoginRequestData {
|
||||
return FacebookLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { booleanOrTrue, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookPsidLoginRequestSchema = z.object({
|
||||
@@ -17,3 +22,10 @@ export type FacebookPsidLoginRequestInput = z.input<
|
||||
export type FacebookPsidLoginRequestData = z.output<
|
||||
typeof FacebookPsidLoginRequestSchema
|
||||
>;
|
||||
|
||||
export type FacebookPsidLoginRequest = SchemaModel<
|
||||
typeof FacebookPsidLoginRequestSchema
|
||||
>;
|
||||
export const FacebookPsidLoginRequest = createSchemaModel(
|
||||
FacebookPsidLoginRequestSchema,
|
||||
);
|
||||
|
||||
@@ -15,3 +15,27 @@ export const FbIdLoginRequestSchema = z.object({
|
||||
|
||||
export type FbIdLoginRequestInput = z.input<typeof FbIdLoginRequestSchema>;
|
||||
export type FbIdLoginRequestData = z.output<typeof FbIdLoginRequestSchema>;
|
||||
|
||||
export class FbIdLoginRequest {
|
||||
declare readonly fbId: string;
|
||||
declare readonly avatarUrl: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: FbIdLoginRequestInput) {
|
||||
const data = FbIdLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: FbIdLoginRequestInput): FbIdLoginRequest {
|
||||
return new FbIdLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FbIdLoginRequest {
|
||||
return FbIdLoginRequest.from(json as FbIdLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): FbIdLoginRequestData {
|
||||
return FbIdLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,28 @@ export const GoogleLoginRequestSchema = z.object({
|
||||
|
||||
export type GoogleLoginRequestInput = z.input<typeof GoogleLoginRequestSchema>;
|
||||
export type GoogleLoginRequestData = z.output<typeof GoogleLoginRequestSchema>;
|
||||
|
||||
export class GoogleLoginRequest {
|
||||
declare readonly idToken: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: GoogleLoginRequestInput) {
|
||||
const data = GoogleLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GoogleLoginRequestInput): GoogleLoginRequest {
|
||||
return new GoogleLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GoogleLoginRequest {
|
||||
return GoogleLoginRequest.from(json as GoogleLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): GoogleLoginRequestData {
|
||||
return GoogleLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,3 +13,26 @@ export const GuestLoginRequestSchema = z.object({
|
||||
|
||||
export type GuestLoginRequestInput = z.input<typeof GuestLoginRequestSchema>;
|
||||
export type GuestLoginRequestData = z.output<typeof GuestLoginRequestSchema>;
|
||||
|
||||
export class GuestLoginRequest {
|
||||
declare readonly deviceId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: GuestLoginRequestInput) {
|
||||
const data = GuestLoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GuestLoginRequestInput): GuestLoginRequest {
|
||||
return new GuestLoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GuestLoginRequest {
|
||||
return GuestLoginRequest.from(json as GuestLoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): GuestLoginRequestData {
|
||||
return GuestLoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,30 @@ export const LoginRequestSchema = z.object({
|
||||
|
||||
export type LoginRequestInput = z.input<typeof LoginRequestSchema>;
|
||||
export type LoginRequestData = z.output<typeof LoginRequestSchema>;
|
||||
|
||||
export class LoginRequest {
|
||||
declare readonly email: string;
|
||||
declare readonly username: string;
|
||||
declare readonly password: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: LoginRequestInput) {
|
||||
const data = LoginRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: LoginRequestInput): LoginRequest {
|
||||
return new LoginRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): LoginRequest {
|
||||
return LoginRequest.from(json as LoginRequestInput);
|
||||
}
|
||||
|
||||
toJson(): LoginRequestData {
|
||||
return LoginRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,23 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
export const RefreshTokenRequestSchema = z.object({
|
||||
refreshToken: z.string(),
|
||||
});
|
||||
|
||||
export type RefreshTokenRequestInput = z.input<typeof RefreshTokenRequestSchema>;
|
||||
export type RefreshTokenRequestData = z.output<typeof RefreshTokenRequestSchema>;
|
||||
|
||||
export type RefreshTokenRequest = SchemaModel<
|
||||
typeof RefreshTokenRequestSchema,
|
||||
"refreshToken"
|
||||
>;
|
||||
export const RefreshTokenRequest = createSchemaModel<
|
||||
typeof RefreshTokenRequestSchema,
|
||||
"refreshToken"
|
||||
>(RefreshTokenRequestSchema);
|
||||
|
||||
@@ -17,3 +17,30 @@ export const RegisterRequestSchema = z.object({
|
||||
|
||||
export type RegisterRequestInput = z.input<typeof RegisterRequestSchema>;
|
||||
export type RegisterRequestData = z.output<typeof RegisterRequestSchema>;
|
||||
|
||||
export class RegisterRequest {
|
||||
declare readonly username: string;
|
||||
declare readonly email: string;
|
||||
declare readonly password: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly guestId: string;
|
||||
declare readonly isTestAccount: boolean;
|
||||
|
||||
private constructor(input: RegisterRequestInput) {
|
||||
const data = RegisterRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RegisterRequestInput): RegisterRequest {
|
||||
return new RegisterRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RegisterRequest {
|
||||
return RegisterRequest.from(json as RegisterRequestInput);
|
||||
}
|
||||
|
||||
toJson(): RegisterRequestData {
|
||||
return RegisterRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { arrayOrEmpty, stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const FacebookIdentityBindingSchema = z.object({
|
||||
@@ -25,3 +30,10 @@ export type FacebookIdentityResponseInput = z.input<
|
||||
export type FacebookIdentityResponseData = z.output<
|
||||
typeof FacebookIdentityResponseSchema
|
||||
>;
|
||||
|
||||
export type FacebookIdentityResponse = SchemaModel<
|
||||
typeof FacebookIdentityResponseSchema
|
||||
>;
|
||||
export const FacebookIdentityResponse = createSchemaModel(
|
||||
FacebookIdentityResponseSchema,
|
||||
);
|
||||
|
||||
@@ -28,3 +28,27 @@ export type FacebookPsidLoginResponseInput = z.input<
|
||||
export type FacebookPsidLoginResponseData = z.output<
|
||||
typeof FacebookPsidLoginResponseSchema
|
||||
>;
|
||||
|
||||
export class FacebookPsidLoginResponse {
|
||||
private constructor(input: FacebookPsidLoginResponseInput) {
|
||||
const data = FacebookPsidLoginResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: FacebookPsidLoginResponseInput,
|
||||
): FacebookPsidLoginResponse {
|
||||
return new FacebookPsidLoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): FacebookPsidLoginResponse {
|
||||
return FacebookPsidLoginResponse.from(
|
||||
json as FacebookPsidLoginResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): FacebookPsidLoginResponseData {
|
||||
return FacebookPsidLoginResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { booleanOrTrue, numberOr, schemaOrNull } from "../../nullable-defaults";
|
||||
import { UserSchema } from "../../user/user";
|
||||
import { User, UserSchema } from "../../user/user";
|
||||
|
||||
export const GuestLoginResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
@@ -18,3 +18,31 @@ export const GuestLoginResponseSchema = z.object({
|
||||
|
||||
export type GuestLoginResponseInput = z.input<typeof GuestLoginResponseSchema>;
|
||||
export type GuestLoginResponseData = z.output<typeof GuestLoginResponseSchema>;
|
||||
|
||||
export class GuestLoginResponse {
|
||||
declare readonly token: string;
|
||||
declare readonly userId: string;
|
||||
declare readonly user: User;
|
||||
|
||||
private constructor(input: GuestLoginResponseInput) {
|
||||
const parsed = GuestLoginResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...parsed,
|
||||
user: parsed.user ? User.fromJson(parsed.user) : User.empty(),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: GuestLoginResponseInput): GuestLoginResponse {
|
||||
return new GuestLoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): GuestLoginResponse {
|
||||
return GuestLoginResponse.from(json as GuestLoginResponseInput);
|
||||
}
|
||||
|
||||
toJson(): GuestLoginResponseData {
|
||||
const data = GuestLoginResponseSchema.parse(this);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
import { UserSchema } from "../../user/user";
|
||||
import { User, UserSchema } from "../../user/user";
|
||||
|
||||
export const LoginResponseSchema = z.object({
|
||||
user: UserSchema,
|
||||
@@ -15,3 +15,30 @@ export const LoginResponseSchema = z.object({
|
||||
|
||||
export type LoginResponseInput = z.input<typeof LoginResponseSchema>;
|
||||
export type LoginResponseData = z.output<typeof LoginResponseSchema>;
|
||||
|
||||
export class LoginResponse {
|
||||
declare readonly user: User;
|
||||
declare readonly token: string;
|
||||
declare readonly refreshToken: string;
|
||||
|
||||
private constructor(input: LoginResponseInput) {
|
||||
const data = LoginResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
user: User.fromJson(data.user),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: LoginResponseInput): LoginResponse {
|
||||
return new LoginResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): LoginResponse {
|
||||
return LoginResponse.from(json as LoginResponseInput);
|
||||
}
|
||||
|
||||
toJson(): LoginResponseData {
|
||||
return LoginResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { booleanOrTrue } from "../../nullable-defaults";
|
||||
|
||||
export const LogoutResponseSchema = z.object({
|
||||
@@ -12,3 +17,12 @@ export const LogoutResponseSchema = z.object({
|
||||
|
||||
export type LogoutResponseInput = z.input<typeof LogoutResponseSchema>;
|
||||
export type LogoutResponseData = z.output<typeof LogoutResponseSchema>;
|
||||
|
||||
export type LogoutResponse = SchemaModel<
|
||||
typeof LogoutResponseSchema,
|
||||
"success"
|
||||
>;
|
||||
export const LogoutResponse = createSchemaModel<
|
||||
typeof LogoutResponseSchema,
|
||||
"success"
|
||||
>(LogoutResponseSchema);
|
||||
|
||||
@@ -12,3 +12,26 @@ export const RefreshTokenResponseSchema = z.object({
|
||||
|
||||
export type RefreshTokenResponseInput = z.input<typeof RefreshTokenResponseSchema>;
|
||||
export type RefreshTokenResponseData = z.output<typeof RefreshTokenResponseSchema>;
|
||||
|
||||
export class RefreshTokenResponse {
|
||||
declare readonly token: string;
|
||||
declare readonly refreshToken: string;
|
||||
|
||||
private constructor(input: RefreshTokenResponseInput) {
|
||||
const data = RefreshTokenResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RefreshTokenResponseInput): RefreshTokenResponse {
|
||||
return new RefreshTokenResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RefreshTokenResponse {
|
||||
return RefreshTokenResponse.from(json as RefreshTokenResponseInput);
|
||||
}
|
||||
|
||||
toJson(): RefreshTokenResponseData {
|
||||
return RefreshTokenResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Character } from "@/data/schemas/character";
|
||||
|
||||
describe("Character", () => {
|
||||
it("keeps only the four public character fields", () => {
|
||||
const character = Character.fromJson({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: " Elio Silvestri ",
|
||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||
enabled: true,
|
||||
sortOrder: 1,
|
||||
});
|
||||
|
||||
expect(character.toJson()).toEqual({
|
||||
id: "character_elio",
|
||||
slug: "elio",
|
||||
displayName: "Elio Silvestri",
|
||||
avatarUrl: "https://cdn.example.com/elio.jpg",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects unsafe slugs and non-HTTPS avatars", () => {
|
||||
expect(() =>
|
||||
Character.from({
|
||||
id: "character_aria",
|
||||
slug: "Aria Profile",
|
||||
displayName: "Aria",
|
||||
avatarUrl: "https://cdn.example.com/aria.jpg",
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
Character.from({
|
||||
id: "character_aria",
|
||||
slug: "aria",
|
||||
displayName: "Aria",
|
||||
avatarUrl: "http://cdn.example.com/aria.jpg",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -11,3 +11,27 @@ export const CharacterSchema = z.object({
|
||||
|
||||
export type CharacterInput = z.input<typeof CharacterSchema>;
|
||||
export type CharacterData = z.output<typeof CharacterSchema>;
|
||||
|
||||
export class Character {
|
||||
declare readonly id: string;
|
||||
declare readonly slug: string;
|
||||
declare readonly displayName: string;
|
||||
declare readonly avatarUrl: string;
|
||||
|
||||
private constructor(input: CharacterInput) {
|
||||
Object.assign(this, CharacterSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharacterInput): Character {
|
||||
return new Character(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): Character {
|
||||
return Character.from(json as CharacterInput);
|
||||
}
|
||||
|
||||
toJson(): CharacterData {
|
||||
return CharacterSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CharacterSchema } from "./character";
|
||||
import { Character, CharacterSchema } from "./character";
|
||||
|
||||
export const CharactersResponseSchema = z.object({
|
||||
items: z.array(CharacterSchema).default([]),
|
||||
@@ -8,3 +8,29 @@ export const CharactersResponseSchema = z.object({
|
||||
|
||||
export type CharactersResponseInput = z.input<typeof CharactersResponseSchema>;
|
||||
export type CharactersResponseData = z.output<typeof CharactersResponseSchema>;
|
||||
|
||||
export class CharactersResponse {
|
||||
declare readonly items: Character[];
|
||||
|
||||
private constructor(input: CharactersResponseInput) {
|
||||
const data = CharactersResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
items: data.items.map((item) => Character.from(item)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: CharactersResponseInput): CharactersResponse {
|
||||
return new CharactersResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CharactersResponse {
|
||||
return CharactersResponse.from(json as CharactersResponseInput);
|
||||
}
|
||||
|
||||
toJson(): CharactersResponseData {
|
||||
return CharactersResponseSchema.parse({
|
||||
items: this.items.map((item) => item.toJson()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./character";
|
||||
export * from "./characters_response";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ChatHistoryResponse } from "@/data/schemas/chat";
|
||||
|
||||
describe("ChatHistoryResponse", () => {
|
||||
it("exposes the pagination total and limit used by chat history", () => {
|
||||
const response = ChatHistoryResponse.from({
|
||||
messages: [],
|
||||
total: 120,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(response.total).toBe(120);
|
||||
expect(response.limit).toBe(50);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_CHARACTER_ID } from "@/data/constants/character";
|
||||
import { UnlockPrivateRequest } from "@/data/schemas/chat";
|
||||
|
||||
describe("UnlockPrivateRequest", () => {
|
||||
it("serializes an existing backend message unlock", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
messageId: "message-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes a temporary promotion lock without a fake message id", () => {
|
||||
expect(
|
||||
UnlockPrivateRequest.from({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
}).toJson(),
|
||||
).toEqual({
|
||||
characterId: DEFAULT_CHARACTER_ID,
|
||||
lockType: "voice_message",
|
||||
clientLockId: "promotion-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UnlockPrivateResponse } from "@/data/schemas/chat";
|
||||
|
||||
describe("UnlockPrivateResponse", () => {
|
||||
it("parses a successful single-message unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
content: "完整消息内容",
|
||||
audioUrl: "https://example.com/unlocked-voice.mp3",
|
||||
reason: "ok",
|
||||
creditBalance: 9180,
|
||||
creditsCharged: 10,
|
||||
requiredCredits: 10,
|
||||
shortfallCredits: 0,
|
||||
});
|
||||
|
||||
expect(response.unlocked).toBe(true);
|
||||
expect(response.content).toBe("完整消息内容");
|
||||
expect(response.audioUrl).toBe("https://example.com/unlocked-voice.mp3");
|
||||
expect(response.reason).toBe("ok");
|
||||
expect(response.creditBalance).toBe(9180);
|
||||
expect(response.creditsCharged).toBe(10);
|
||||
expect(response.requiredCredits).toBe(10);
|
||||
expect(response.shortfallCredits).toBe(0);
|
||||
});
|
||||
|
||||
it("parses canonical ids, audio, and unlocked images", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: true,
|
||||
messageId: "backend-message-1",
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
content: "Unlocked reply",
|
||||
audioUrl: "https://example.com/audio.mp3",
|
||||
image: {
|
||||
type: "promotion",
|
||||
url: "https://example.com/image.jpg",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.messageId).toBe("backend-message-1");
|
||||
expect(response.content).toBe("Unlocked reply");
|
||||
expect(response.audioUrl).toBe("https://example.com/audio.mp3");
|
||||
expect(response.image.url).toBe("https://example.com/image.jpg");
|
||||
expect(response.toJson()).toMatchObject({
|
||||
clientLockId: "promotion-1",
|
||||
lockType: "image_paywall",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses an insufficient-credits unlock response", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_credits",
|
||||
creditBalance: 5,
|
||||
creditsCharged: 0,
|
||||
requiredCredits: 10,
|
||||
shortfallCredits: 5,
|
||||
});
|
||||
|
||||
expect(response.unlocked).toBe(false);
|
||||
expect(response.reason).toBe("insufficient_credits");
|
||||
expect(response.creditBalance).toBe(5);
|
||||
expect(response.creditsCharged).toBe(0);
|
||||
expect(response.requiredCredits).toBe(10);
|
||||
expect(response.shortfallCredits).toBe(5);
|
||||
});
|
||||
|
||||
it("defaults nullable content to an empty string", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
content: null,
|
||||
audioUrl: null,
|
||||
reason: "insufficient_credits",
|
||||
});
|
||||
|
||||
expect(response.content).toBe("");
|
||||
expect(response.audioUrl).toBe("");
|
||||
});
|
||||
|
||||
it("defaults nullable credit fields to zero", () => {
|
||||
const response = UnlockPrivateResponse.from({
|
||||
unlocked: false,
|
||||
content: "",
|
||||
reason: "insufficient_credits",
|
||||
creditBalance: null,
|
||||
creditsCharged: null,
|
||||
requiredCredits: null,
|
||||
shortfallCredits: null,
|
||||
});
|
||||
|
||||
expect(response.creditBalance).toBe(0);
|
||||
expect(response.creditsCharged).toBe(0);
|
||||
expect(response.requiredCredits).toBe(0);
|
||||
expect(response.shortfallCredits).toBe(0);
|
||||
});
|
||||
|
||||
it("strips legacy lock detail fields", () => {
|
||||
const response = UnlockPrivateResponse.fromJson({
|
||||
unlocked: false,
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
reason: "private_message",
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
hint: "Legacy hint",
|
||||
detail: { messageId: "legacy-message" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.toJson()).not.toHaveProperty("lockDetail");
|
||||
expect(response).not.toHaveProperty("lockDetail");
|
||||
});
|
||||
|
||||
it("does not fall back to removed response aliases", () => {
|
||||
const response = UnlockPrivateResponse.fromJson({
|
||||
unlocked: true,
|
||||
message_id: "legacy-message",
|
||||
reply: "Legacy reply",
|
||||
audio_url: "https://example.com/legacy.mp3",
|
||||
});
|
||||
|
||||
expect(response.messageId).toBe("");
|
||||
expect(response.content).toBe("");
|
||||
expect(response.audioUrl).toBe("");
|
||||
expect(response.toJson()).not.toHaveProperty("message_id");
|
||||
expect(response.toJson()).not.toHaveProperty("reply");
|
||||
expect(response.toJson()).not.toHaveProperty("audio_url");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export type ChatMediaKind = "image" | "audio";
|
||||
@@ -5,7 +5,12 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { stringOr, stringOrEmpty, stringOrNull } from "../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "./chat_payloads";
|
||||
import {
|
||||
ChatImageSchema,
|
||||
ChatLockDetailSchema,
|
||||
type ChatImageData,
|
||||
type ChatLockDetailData,
|
||||
} from "./chat_payloads";
|
||||
|
||||
export const ChatMessageSchema = z
|
||||
.object({
|
||||
@@ -26,3 +31,32 @@ export const ChatMessageSchema = z
|
||||
|
||||
export type ChatMessageInput = z.input<typeof ChatMessageSchema>;
|
||||
export type ChatMessageData = z.output<typeof ChatMessageSchema>;
|
||||
|
||||
export class ChatMessage {
|
||||
declare readonly role: string;
|
||||
declare readonly type: string;
|
||||
declare readonly content: string;
|
||||
declare readonly id: string;
|
||||
declare readonly createdAt: string;
|
||||
declare readonly audioUrl: string | null;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly lockDetail: ChatLockDetailData;
|
||||
|
||||
private constructor(input: ChatMessageInput) {
|
||||
const data = ChatMessageSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatMessageInput): ChatMessage {
|
||||
return new ChatMessage(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatMessage {
|
||||
return ChatMessage.from(json as ChatMessageInput);
|
||||
}
|
||||
|
||||
toJson(): ChatMessageData {
|
||||
return ChatMessageSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
*/
|
||||
|
||||
export * from "./chat_lock_type";
|
||||
export * from "./chat_media";
|
||||
export * from "./chat_message";
|
||||
export * from "./chat_payloads";
|
||||
export * from "./request/send_message_request";
|
||||
export * from "./request/unlock_history_request";
|
||||
export * from "./request/unlock_private_request";
|
||||
export * from "./response/chat_history_response";
|
||||
export * from "./response/chat_send_response";
|
||||
|
||||
@@ -25,3 +25,33 @@ export const SendMessageRequestSchema = z.object({
|
||||
|
||||
export type SendMessageRequestInput = z.input<typeof SendMessageRequestSchema>;
|
||||
export type SendMessageRequestData = z.output<typeof SendMessageRequestSchema>;
|
||||
|
||||
export class SendMessageRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly message: string;
|
||||
declare readonly image: string;
|
||||
declare readonly imageId: string;
|
||||
declare readonly imageThumbUrl: string;
|
||||
declare readonly imageMediumUrl: string;
|
||||
declare readonly imageOriginalUrl: string;
|
||||
declare readonly imageWidth: number;
|
||||
declare readonly imageHeight: number;
|
||||
|
||||
private constructor(input: SendMessageRequestInput) {
|
||||
const data = SendMessageRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: SendMessageRequestInput): SendMessageRequest {
|
||||
return new SendMessageRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): SendMessageRequest {
|
||||
return SendMessageRequest.from(json as SendMessageRequestInput);
|
||||
}
|
||||
|
||||
toJson(): SendMessageRequestData {
|
||||
return SendMessageRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,3 +10,20 @@ export type UnlockHistoryRequestInput = z.input<
|
||||
export type UnlockHistoryRequestData = z.output<
|
||||
typeof UnlockHistoryRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockHistoryRequest {
|
||||
declare readonly characterId: string;
|
||||
|
||||
private constructor(input: UnlockHistoryRequestInput) {
|
||||
Object.assign(this, UnlockHistoryRequestSchema.parse(input));
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockHistoryRequestInput): UnlockHistoryRequest {
|
||||
return new UnlockHistoryRequest(input);
|
||||
}
|
||||
|
||||
toJson(): UnlockHistoryRequestData {
|
||||
return UnlockHistoryRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,3 +22,28 @@ export type UnlockPrivateRequestInput = z.input<
|
||||
export type UnlockPrivateRequestData = z.output<
|
||||
typeof UnlockPrivateRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockPrivateRequest {
|
||||
declare readonly characterId: string;
|
||||
declare readonly messageId?: string;
|
||||
declare readonly lockType?: UnlockPrivateRequestData["lockType"];
|
||||
declare readonly clientLockId?: string;
|
||||
|
||||
private constructor(input: UnlockPrivateRequestInput) {
|
||||
const data = UnlockPrivateRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateRequestInput): UnlockPrivateRequest {
|
||||
return new UnlockPrivateRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockPrivateRequest {
|
||||
return UnlockPrivateRequest.from(json as UnlockPrivateRequestInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateRequestData {
|
||||
return UnlockPrivateRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatMessageSchema } from "../chat_message";
|
||||
import { ChatMessage, ChatMessageSchema } from "../chat_message";
|
||||
|
||||
export const ChatHistoryResponseSchema = z.object({
|
||||
messages: arrayOrEmpty(ChatMessageSchema),
|
||||
@@ -24,3 +24,30 @@ export const ChatHistoryResponseSchema = z.object({
|
||||
|
||||
export type ChatHistoryResponseInput = z.input<typeof ChatHistoryResponseSchema>;
|
||||
export type ChatHistoryResponseData = z.output<typeof ChatHistoryResponseSchema>;
|
||||
|
||||
export class ChatHistoryResponse {
|
||||
declare readonly messages: ChatMessage[];
|
||||
declare readonly total: number;
|
||||
declare readonly limit: number;
|
||||
|
||||
private constructor(input: ChatHistoryResponseInput) {
|
||||
const data = ChatHistoryResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
messages: data.messages.map((m) => ChatMessage.from(m)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatHistoryResponseInput): ChatHistoryResponse {
|
||||
return new ChatHistoryResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatHistoryResponse {
|
||||
return ChatHistoryResponse.from(json as ChatHistoryResponseInput);
|
||||
}
|
||||
|
||||
toJson(): ChatHistoryResponseData {
|
||||
return ChatHistoryResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
numberOrZero,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatImageSchema, ChatLockDetailSchema } from "../chat_payloads";
|
||||
import {
|
||||
ChatImageSchema,
|
||||
ChatLockDetailSchema,
|
||||
type ChatImageData,
|
||||
type ChatLockDetailData,
|
||||
} from "../chat_payloads";
|
||||
|
||||
export const ChatSendResponseSchema = z.object({
|
||||
reply: stringOrEmpty,
|
||||
@@ -30,3 +35,35 @@ export const ChatSendResponseSchema = z.object({
|
||||
|
||||
export type ChatSendResponseInput = z.input<typeof ChatSendResponseSchema>;
|
||||
export type ChatSendResponseData = z.output<typeof ChatSendResponseSchema>;
|
||||
|
||||
export class ChatSendResponse {
|
||||
declare readonly reply: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly messageId: string;
|
||||
declare readonly timestamp: number;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly lockDetail: ChatLockDetailData;
|
||||
declare readonly canSendMessage: boolean;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: ChatSendResponseInput) {
|
||||
const data = ChatSendResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: ChatSendResponseInput): ChatSendResponse {
|
||||
return new ChatSendResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): ChatSendResponse {
|
||||
return ChatSendResponse.from(json as ChatSendResponseInput);
|
||||
}
|
||||
|
||||
toJson(): ChatSendResponseData {
|
||||
return ChatSendResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,3 +41,27 @@ export type UnlockHistoryResponseInput = z.input<
|
||||
export type UnlockHistoryResponseData = z.output<
|
||||
typeof UnlockHistoryResponseSchema
|
||||
>;
|
||||
|
||||
export class UnlockHistoryResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly reason: UnlockHistoryReason;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: UnlockHistoryResponseInput) {
|
||||
const data = UnlockHistoryResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockHistoryResponseInput): UnlockHistoryResponse {
|
||||
return new UnlockHistoryResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockHistoryResponse {
|
||||
return UnlockHistoryResponse.from(json as UnlockHistoryResponseInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockHistoryResponseData {
|
||||
return UnlockHistoryResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
stringOrEmpty,
|
||||
stringOrNull,
|
||||
} from "../../nullable-defaults";
|
||||
import { ChatImageSchema } from "../chat_payloads";
|
||||
import { ChatImageSchema, type ChatImageData } from "../chat_payloads";
|
||||
import { ChatLockTypeSchema } from "../chat_lock_type";
|
||||
|
||||
/**
|
||||
@@ -36,3 +36,37 @@ export type UnlockPrivateResponseInput = z.input<
|
||||
export type UnlockPrivateResponseData = z.output<
|
||||
typeof UnlockPrivateResponseSchema
|
||||
>;
|
||||
|
||||
/**
|
||||
* 单条历史付费 / 私密消息解锁响应数据类。
|
||||
*/
|
||||
export class UnlockPrivateResponse {
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly messageId: string;
|
||||
declare readonly content: string;
|
||||
declare readonly audioUrl: string;
|
||||
declare readonly image: ChatImageData;
|
||||
declare readonly reason: UnlockPrivateReason;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly creditsCharged: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
|
||||
private constructor(input: UnlockPrivateResponseInput) {
|
||||
const data = UnlockPrivateResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateResponseInput): UnlockPrivateResponse {
|
||||
return new UnlockPrivateResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UnlockPrivateResponse {
|
||||
return UnlockPrivateResponse.from(json as UnlockPrivateResponseInput);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateResponseData {
|
||||
return UnlockPrivateResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1 +1,6 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./feedback_submission";
|
||||
export * from "./response";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
export const FeedbackSubmitResponseSchema = z.object({
|
||||
feedbackId: z.string().min(1),
|
||||
});
|
||||
@@ -10,3 +15,12 @@ export type FeedbackSubmitResponseInput = z.input<
|
||||
export type FeedbackSubmitResponseData = z.output<
|
||||
typeof FeedbackSubmitResponseSchema
|
||||
>;
|
||||
|
||||
export type FeedbackSubmitResponse = SchemaModel<
|
||||
typeof FeedbackSubmitResponseSchema,
|
||||
"feedbackId"
|
||||
>;
|
||||
export const FeedbackSubmitResponse = createSchemaModel<
|
||||
typeof FeedbackSubmitResponseSchema,
|
||||
"feedbackId"
|
||||
>(FeedbackSubmitResponseSchema);
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./feedback_submit_response";
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import { stringOrEmpty } from "../../nullable-defaults";
|
||||
|
||||
export const AppEventSchema = z.object({
|
||||
@@ -14,3 +19,10 @@ export const AppEventSchema = z.object({
|
||||
|
||||
export type AppEventInput = z.input<typeof AppEventSchema>;
|
||||
export type AppEventData = z.output<typeof AppEventSchema>;
|
||||
|
||||
type AppEventPublicKey = "userId" | "browser" | "userAgent";
|
||||
export type AppEvent = SchemaModel<typeof AppEventSchema, AppEventPublicKey>;
|
||||
export const AppEvent = createSchemaModel<
|
||||
typeof AppEventSchema,
|
||||
AppEventPublicKey
|
||||
>(AppEventSchema);
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createSchemaModel,
|
||||
type SchemaModel,
|
||||
} from "../../schema_model";
|
||||
|
||||
import {
|
||||
booleanOrFalse,
|
||||
numberOrZero,
|
||||
@@ -20,3 +25,15 @@ export const PwaEventSchema = z.object({
|
||||
|
||||
export type PwaEventInput = z.input<typeof PwaEventSchema>;
|
||||
export type PwaEventData = z.output<typeof PwaEventSchema>;
|
||||
|
||||
type PwaEventPublicKey =
|
||||
| "deviceId"
|
||||
| "deviceType"
|
||||
| "timestamp"
|
||||
| "pwaInstalled"
|
||||
| "pwaSupported";
|
||||
export type PwaEvent = SchemaModel<typeof PwaEventSchema, PwaEventPublicKey>;
|
||||
export const PwaEvent = createSchemaModel<
|
||||
typeof PwaEventSchema,
|
||||
PwaEventPublicKey
|
||||
>(PwaEventSchema);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
PaymentPlan,
|
||||
PaymentPlansResponse,
|
||||
TipPaymentPlansResponse,
|
||||
} from "@/data/schemas/payment";
|
||||
|
||||
describe("PaymentPlan", () => {
|
||||
it("parses the camelCase payment plan shape", () => {
|
||||
const plan = PaymentPlan.from({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 3000,
|
||||
amountCents: 1999,
|
||||
originalAmountCents: 2499,
|
||||
dailyPriceCents: 66,
|
||||
currency: "JPY",
|
||||
mostPopular: true,
|
||||
});
|
||||
|
||||
expect(plan.planId).toBe("vip_monthly");
|
||||
expect(plan.creditBalance).toBe(3000);
|
||||
expect(plan.mostPopular).toBe(true);
|
||||
expect(plan.toJson()).toEqual({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 3000,
|
||||
amountCents: 1999,
|
||||
originalAmountCents: 2499,
|
||||
dailyPriceCents: 66,
|
||||
currency: "JPY",
|
||||
isFirstRechargeOffer: false,
|
||||
mostPopular: true,
|
||||
firstRechargeDiscountPercent: null,
|
||||
promotionType: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the legacy snake_case payment plan shape", () => {
|
||||
expect(() =>
|
||||
PaymentPlan.fromJson({
|
||||
plan_id: "vip_monthly",
|
||||
plan_name: "VIP Monthly",
|
||||
order_type: "vip_monthly",
|
||||
amount_cents: 1999,
|
||||
original_amount_cents: 2499,
|
||||
daily_price_cents: 66,
|
||||
currency: "JPY",
|
||||
vip_days: 30,
|
||||
dol_amount: null,
|
||||
}),
|
||||
).toThrow(z.ZodError);
|
||||
});
|
||||
|
||||
it("defaults mostPopular to false when the backend omits it", () => {
|
||||
const plan = PaymentPlan.from({
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 3000,
|
||||
amountCents: 1999,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
expect(plan.mostPopular).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PaymentPlansResponse", () => {
|
||||
it("parses plans from the new backend response data shape", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
plans: [
|
||||
{
|
||||
planId: "dol_1000",
|
||||
planName: "1000 Credits",
|
||||
orderType: "dol",
|
||||
vipDays: null,
|
||||
dolAmount: 1000,
|
||||
creditBalance: 1000,
|
||||
amountCents: 990,
|
||||
originalAmountCents: null,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.plans).toHaveLength(1);
|
||||
expect(response.plans[0]?.dolAmount).toBe(1000);
|
||||
});
|
||||
|
||||
it("parses first recharge offer metadata", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
enabled: true,
|
||||
type: "first_recharge_half_price",
|
||||
discountPercent: 50,
|
||||
},
|
||||
plans: [
|
||||
{
|
||||
planId: "vip_monthly",
|
||||
planName: "VIP Monthly",
|
||||
orderType: "vip_monthly",
|
||||
vipDays: 30,
|
||||
dolAmount: null,
|
||||
creditBalance: 3000,
|
||||
amountCents: 995,
|
||||
originalAmountCents: 1990,
|
||||
dailyPriceCents: null,
|
||||
currency: "USD",
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
promotionType: "first_recharge_half_price",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.isFirstRecharge).toBe(true);
|
||||
expect(response.firstRechargeOffer?.discountPercent).toBe(50);
|
||||
expect(response.plans[0]).toMatchObject({
|
||||
amountCents: 995,
|
||||
originalAmountCents: 1990,
|
||||
isFirstRechargeOffer: true,
|
||||
firstRechargeDiscountPercent: 50,
|
||||
promotionType: "first_recharge_half_price",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults nullable first recharge offer metadata", () => {
|
||||
const response = PaymentPlansResponse.from({
|
||||
isFirstRecharge: true,
|
||||
firstRechargeOffer: {
|
||||
enabled: null,
|
||||
type: null,
|
||||
discountPercent: null,
|
||||
},
|
||||
plans: [],
|
||||
});
|
||||
|
||||
expect(response.firstRechargeOffer).toEqual({
|
||||
enabled: false,
|
||||
type: "",
|
||||
discountPercent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TipPaymentPlansResponse", () => {
|
||||
it("keeps only fields used by the tip payment flow", () => {
|
||||
const response = TipPaymentPlansResponse.fromJson({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
orderType: "tip",
|
||||
tipType: "coffee_small",
|
||||
description: "Buy Elio a small coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
autoRenew: false,
|
||||
isFirstRechargeOffer: false,
|
||||
firstRechargeDiscountPercent: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.toJson()).toEqual({
|
||||
plans: [
|
||||
{
|
||||
planId: "tip_coffee_usd_4_99",
|
||||
planName: "Small Coffee",
|
||||
amountCents: 499,
|
||||
currency: "USD",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -28,3 +28,37 @@ export const PaymentPlanSchema = z.object({
|
||||
|
||||
export type PaymentPlanInput = z.input<typeof PaymentPlanSchema>;
|
||||
export type PaymentPlanData = z.output<typeof PaymentPlanSchema>;
|
||||
|
||||
export class PaymentPlan {
|
||||
declare readonly planId: string;
|
||||
declare readonly planName: string;
|
||||
declare readonly orderType: string;
|
||||
declare readonly vipDays: number | null;
|
||||
declare readonly dolAmount: number | null;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly amountCents: number;
|
||||
declare readonly originalAmountCents: number | null;
|
||||
declare readonly currency: string;
|
||||
declare readonly isFirstRechargeOffer: boolean;
|
||||
declare readonly mostPopular: boolean;
|
||||
declare readonly firstRechargeDiscountPercent: number | null;
|
||||
declare readonly promotionType: string | null;
|
||||
|
||||
private constructor(input: PaymentPlanInput) {
|
||||
const data = PaymentPlanSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PaymentPlanInput): PaymentPlan {
|
||||
return new PaymentPlan(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentPlan {
|
||||
return PaymentPlan.from(json as PaymentPlanInput);
|
||||
}
|
||||
|
||||
toJson(): PaymentPlanData {
|
||||
return PaymentPlanSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,3 +18,31 @@ export type CreatePaymentOrderRequestInput = z.input<
|
||||
export type CreatePaymentOrderRequestData = z.output<
|
||||
typeof CreatePaymentOrderRequestSchema
|
||||
>;
|
||||
|
||||
export class CreatePaymentOrderRequest {
|
||||
declare readonly planId: string;
|
||||
declare readonly payChannel: PayChannel;
|
||||
declare readonly autoRenew: boolean;
|
||||
|
||||
private constructor(input: CreatePaymentOrderRequestInput) {
|
||||
const data = CreatePaymentOrderRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: CreatePaymentOrderRequestInput,
|
||||
): CreatePaymentOrderRequest {
|
||||
return new CreatePaymentOrderRequest(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CreatePaymentOrderRequest {
|
||||
return CreatePaymentOrderRequest.from(
|
||||
json as CreatePaymentOrderRequestInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): CreatePaymentOrderRequestData {
|
||||
return CreatePaymentOrderRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,3 +55,30 @@ export type CreatePaymentOrderResponseInput = z.input<
|
||||
export type CreatePaymentOrderResponseData = z.output<
|
||||
typeof CreatePaymentOrderResponseSchema
|
||||
>;
|
||||
|
||||
export class CreatePaymentOrderResponse {
|
||||
declare readonly orderId: string;
|
||||
declare readonly payParams: Record<string, unknown>;
|
||||
|
||||
private constructor(input: CreatePaymentOrderResponseInput) {
|
||||
const data = CreatePaymentOrderResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: CreatePaymentOrderResponseInput,
|
||||
): CreatePaymentOrderResponse {
|
||||
return new CreatePaymentOrderResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): CreatePaymentOrderResponse {
|
||||
return CreatePaymentOrderResponse.from(
|
||||
json as CreatePaymentOrderResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): CreatePaymentOrderResponseData {
|
||||
return CreatePaymentOrderResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,3 +19,29 @@ export type PaymentOrderStatusResponseInput = z.input<
|
||||
export type PaymentOrderStatusResponseData = z.output<
|
||||
typeof PaymentOrderStatusResponseSchema
|
||||
>;
|
||||
|
||||
export class PaymentOrderStatusResponse {
|
||||
declare readonly status: PaymentOrderStatus;
|
||||
|
||||
private constructor(input: PaymentOrderStatusResponseInput) {
|
||||
const data = PaymentOrderStatusResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: PaymentOrderStatusResponseInput,
|
||||
): PaymentOrderStatusResponse {
|
||||
return new PaymentOrderStatusResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentOrderStatusResponse {
|
||||
return PaymentOrderStatusResponse.from(
|
||||
json as PaymentOrderStatusResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): PaymentOrderStatusResponseData {
|
||||
return PaymentOrderStatusResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
schemaOrNull,
|
||||
stringOrEmpty,
|
||||
} from "../../nullable-defaults";
|
||||
import { PaymentPlanSchema } from "../payment_plan";
|
||||
import { PaymentPlan, PaymentPlanSchema } from "../payment_plan";
|
||||
|
||||
export const FirstRechargeOfferSchema = z.object({
|
||||
enabled: booleanOrFalse,
|
||||
@@ -31,3 +31,37 @@ export type PaymentPlansResponseData = z.output<
|
||||
typeof PaymentPlansResponseSchema
|
||||
>;
|
||||
export type FirstRechargeOfferData = z.output<typeof FirstRechargeOfferSchema>;
|
||||
|
||||
export class PaymentPlansResponse {
|
||||
declare readonly isFirstRecharge: boolean;
|
||||
declare readonly firstRechargeOffer: FirstRechargeOfferData | null;
|
||||
declare readonly plans: PaymentPlan[];
|
||||
|
||||
private constructor(input: PaymentPlansResponseInput) {
|
||||
const data = PaymentPlansResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
isFirstRecharge: data.isFirstRecharge,
|
||||
firstRechargeOffer: data.firstRechargeOffer,
|
||||
plans: data.plans.map((plan) => PaymentPlan.from(plan)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PaymentPlansResponseInput): PaymentPlansResponse {
|
||||
return new PaymentPlansResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PaymentPlansResponse {
|
||||
return PaymentPlansResponse.from(json as PaymentPlansResponseInput);
|
||||
}
|
||||
|
||||
toJson(): PaymentPlansResponseData {
|
||||
return {
|
||||
isFirstRecharge: this.isFirstRecharge,
|
||||
firstRechargeOffer: this.firstRechargeOffer,
|
||||
plans: this.plans.map((plan) => plan.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type FirstRechargeOffer = FirstRechargeOfferData;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty } from "../../nullable-defaults";
|
||||
import { TipPaymentPlanSchema } from "../tip_payment_plan";
|
||||
import { TipPaymentPlan, TipPaymentPlanSchema } from "../tip_payment_plan";
|
||||
|
||||
export const TipPaymentPlansResponseSchema = z.object({
|
||||
plans: arrayOrEmpty(TipPaymentPlanSchema),
|
||||
@@ -13,3 +13,31 @@ export type TipPaymentPlansResponseInput = z.input<
|
||||
export type TipPaymentPlansResponseData = z.output<
|
||||
typeof TipPaymentPlansResponseSchema
|
||||
>;
|
||||
|
||||
export class TipPaymentPlansResponse {
|
||||
declare readonly plans: TipPaymentPlan[];
|
||||
|
||||
private constructor(input: TipPaymentPlansResponseInput) {
|
||||
const data = TipPaymentPlansResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
plans: data.plans.map((plan) => TipPaymentPlan.from(plan)),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlansResponseInput): TipPaymentPlansResponse {
|
||||
return new TipPaymentPlansResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlansResponse {
|
||||
return TipPaymentPlansResponse.from(
|
||||
json as TipPaymentPlansResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlansResponseData {
|
||||
return {
|
||||
plans: this.plans.map((plan) => plan.toJson()),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,28 @@ export const TipPaymentPlanSchema = z.object({
|
||||
|
||||
export type TipPaymentPlanInput = z.input<typeof TipPaymentPlanSchema>;
|
||||
export type TipPaymentPlanData = z.output<typeof TipPaymentPlanSchema>;
|
||||
|
||||
export class TipPaymentPlan {
|
||||
declare readonly planId: string;
|
||||
declare readonly planName: string;
|
||||
declare readonly amountCents: number;
|
||||
declare readonly currency: string;
|
||||
|
||||
private constructor(input: TipPaymentPlanInput) {
|
||||
const data = TipPaymentPlanSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: TipPaymentPlanInput): TipPaymentPlan {
|
||||
return new TipPaymentPlan(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): TipPaymentPlan {
|
||||
return TipPaymentPlan.from(json as TipPaymentPlanInput);
|
||||
}
|
||||
|
||||
toJson(): TipPaymentPlanData {
|
||||
return TipPaymentPlanSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PrivateAlbumsResponse,
|
||||
PrivateAlbumUnlockResponse,
|
||||
UnlockPrivateAlbumRequest,
|
||||
} from "@/data/schemas/private-room";
|
||||
import { ApiPath } from "@/data/services/api";
|
||||
|
||||
const ALBUM_ID = "a1b2c3d4-0000-0000-0000-000000000000";
|
||||
const COVER_URL =
|
||||
"https://dbapi.banlv-ai.com/storage/v1/object/public/elio-schedules/01.jpg";
|
||||
|
||||
const lockedAlbum = {
|
||||
albumId: ALBUM_ID,
|
||||
momentId: `album:${ALBUM_ID}`,
|
||||
characterId: "elio",
|
||||
collectionKey: "manila_202607",
|
||||
title: "Private Manila set",
|
||||
content: null,
|
||||
previewText: "Only for you.",
|
||||
imageCount: 8,
|
||||
mediaCount: 8,
|
||||
images: [{ url: COVER_URL, type: "image", locked: true, index: 0 }],
|
||||
locked: true,
|
||||
unlocked: false,
|
||||
unlockCost: 320,
|
||||
requiredCredits: 320,
|
||||
creditCostPerImage: 40,
|
||||
currency: "credits",
|
||||
canUnlockWithCredits: false,
|
||||
publishedAt: "2026-07-13T00:00:00+00:00",
|
||||
lockDetail: {
|
||||
locked: true,
|
||||
showContent: false,
|
||||
showUpgrade: true,
|
||||
reason: "private_album",
|
||||
},
|
||||
};
|
||||
|
||||
describe("Private Album schema models", () => {
|
||||
it("keeps a locked album cover URL while preserving the lock state", () => {
|
||||
const response = PrivateAlbumsResponse.fromJson({
|
||||
items: [lockedAlbum],
|
||||
creditBalance: 100,
|
||||
nextCursor: null,
|
||||
hasMore: false,
|
||||
});
|
||||
|
||||
expect(response.items[0]?.locked).toBe(true);
|
||||
expect(response.items[0]?.unlocked).toBe(false);
|
||||
expect(response.items[0]?.images[0]?.url).toBe(COVER_URL);
|
||||
expect(response.items[0]?.lockDetail).toEqual({ locked: true });
|
||||
});
|
||||
|
||||
it("strips response fields that are not used by the frontend", () => {
|
||||
const response = PrivateAlbumsResponse.fromJson({
|
||||
items: [lockedAlbum],
|
||||
creditBalance: 100,
|
||||
packageOptions: [{ imageCount: 8, creditCost: 320 }],
|
||||
});
|
||||
const albumJson = response.items[0]?.toJson();
|
||||
|
||||
expect(albumJson).not.toHaveProperty("momentId");
|
||||
expect(albumJson).not.toHaveProperty("characterId");
|
||||
expect(albumJson).not.toHaveProperty("collectionKey");
|
||||
expect(response.toJson()).not.toHaveProperty("packageOptions");
|
||||
});
|
||||
|
||||
it("parses an unlock response with all album images", () => {
|
||||
const response = PrivateAlbumUnlockResponse.fromJson({
|
||||
albumId: ALBUM_ID,
|
||||
locked: false,
|
||||
unlocked: true,
|
||||
reason: "ok",
|
||||
unlockCost: 320,
|
||||
creditBalance: 180,
|
||||
images: Array.from({ length: 8 }, (_, index) => ({
|
||||
url: `${COVER_URL}?image=${index}`,
|
||||
locked: false,
|
||||
index,
|
||||
})),
|
||||
creditsCharged: 320,
|
||||
});
|
||||
|
||||
expect(response.unlocked).toBe(true);
|
||||
expect(response.images).toHaveLength(8);
|
||||
expect(response.creditBalance).toBe(180);
|
||||
expect(response.toJson()).not.toHaveProperty("creditsCharged");
|
||||
});
|
||||
|
||||
it("serializes the expected unlock cost", () => {
|
||||
expect(
|
||||
UnlockPrivateAlbumRequest.from({ expectedCost: 320 }).toJson(),
|
||||
).toEqual({ expectedCost: 320 });
|
||||
});
|
||||
|
||||
it("encodes the album id in the unlock path", () => {
|
||||
expect(ApiPath.privateRoomAlbumUnlock("album:91")).toBe(
|
||||
"/api/private-room/albums/album%3A91/unlock",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -36,3 +36,35 @@ export const PrivateAlbumSchema = z.object({
|
||||
export type PrivateAlbumImageData = z.output<typeof PrivateAlbumImageSchema>;
|
||||
export type PrivateAlbumInput = z.input<typeof PrivateAlbumSchema>;
|
||||
export type PrivateAlbumData = z.output<typeof PrivateAlbumSchema>;
|
||||
|
||||
export class PrivateAlbum {
|
||||
declare readonly albumId: string;
|
||||
declare readonly title: string;
|
||||
declare readonly content: string | null;
|
||||
declare readonly previewText: string;
|
||||
declare readonly imageCount: number;
|
||||
declare readonly images: PrivateAlbumData["images"];
|
||||
declare readonly locked: boolean;
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly unlockCost: number;
|
||||
declare readonly publishedAt: string | null;
|
||||
declare readonly lockDetail: PrivateAlbumData["lockDetail"];
|
||||
|
||||
private constructor(input: PrivateAlbumInput) {
|
||||
const data = PrivateAlbumSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumInput): PrivateAlbum {
|
||||
return new PrivateAlbum(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbum {
|
||||
return PrivateAlbum.from(json as PrivateAlbumInput);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumData {
|
||||
return PrivateAlbumSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,3 +12,19 @@ export type UnlockPrivateAlbumRequestInput = z.input<
|
||||
export type UnlockPrivateAlbumRequestData = z.output<
|
||||
typeof UnlockPrivateAlbumRequestSchema
|
||||
>;
|
||||
|
||||
export class UnlockPrivateAlbumRequest {
|
||||
private constructor(input: UnlockPrivateAlbumRequestInput) {
|
||||
const data = UnlockPrivateAlbumRequestSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UnlockPrivateAlbumRequestInput): UnlockPrivateAlbumRequest {
|
||||
return new UnlockPrivateAlbumRequest(input);
|
||||
}
|
||||
|
||||
toJson(): UnlockPrivateAlbumRequestData {
|
||||
return UnlockPrivateAlbumRequestSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +40,35 @@ export type PrivateAlbumUnlockResponseInput = z.input<
|
||||
export type PrivateAlbumUnlockResponseData = z.output<
|
||||
typeof PrivateAlbumUnlockResponseSchema
|
||||
>;
|
||||
|
||||
export class PrivateAlbumUnlockResponse {
|
||||
declare readonly albumId: string;
|
||||
declare readonly locked: boolean;
|
||||
declare readonly unlocked: boolean;
|
||||
declare readonly reason: PrivateAlbumUnlockReason | string;
|
||||
declare readonly unlockCost: number;
|
||||
declare readonly requiredCredits: number;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly shortfallCredits: number;
|
||||
declare readonly images: PrivateAlbumUnlockResponseData["images"];
|
||||
|
||||
private constructor(input: PrivateAlbumUnlockResponseInput) {
|
||||
const data = PrivateAlbumUnlockResponseSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumUnlockResponseInput): PrivateAlbumUnlockResponse {
|
||||
return new PrivateAlbumUnlockResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbumUnlockResponse {
|
||||
return PrivateAlbumUnlockResponse.from(
|
||||
json as PrivateAlbumUnlockResponseInput,
|
||||
);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumUnlockResponseData {
|
||||
return PrivateAlbumUnlockResponseSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { arrayOrEmpty, numberOrZero } from "../../nullable-defaults";
|
||||
import { PrivateAlbumSchema } from "../private_album";
|
||||
import { PrivateAlbum, PrivateAlbumSchema } from "../private_album";
|
||||
|
||||
export const PrivateAlbumsResponseSchema = z.object({
|
||||
items: arrayOrEmpty(PrivateAlbumSchema),
|
||||
@@ -14,3 +14,32 @@ export type PrivateAlbumsResponseInput = z.input<
|
||||
export type PrivateAlbumsResponseData = z.output<
|
||||
typeof PrivateAlbumsResponseSchema
|
||||
>;
|
||||
|
||||
export class PrivateAlbumsResponse {
|
||||
declare readonly items: PrivateAlbum[];
|
||||
declare readonly creditBalance: number;
|
||||
|
||||
private constructor(input: PrivateAlbumsResponseInput) {
|
||||
const data = PrivateAlbumsResponseSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
items: data.items.map((item) => PrivateAlbum.from(item)),
|
||||
creditBalance: data.creditBalance,
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PrivateAlbumsResponseInput): PrivateAlbumsResponse {
|
||||
return new PrivateAlbumsResponse(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PrivateAlbumsResponse {
|
||||
return PrivateAlbumsResponse.from(json as PrivateAlbumsResponseInput);
|
||||
}
|
||||
|
||||
toJson(): PrivateAlbumsResponseData {
|
||||
return {
|
||||
items: this.items.map((item) => item.toJson()),
|
||||
creditBalance: this.creditBalance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
export type SchemaModel<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
> = Readonly<Pick<z.output<TSchema>, TPublicKey>> & {
|
||||
toJson(): z.output<TSchema>;
|
||||
};
|
||||
|
||||
export interface SchemaModelFactory<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
> {
|
||||
readonly prototype: SchemaModel<TSchema, TPublicKey>;
|
||||
[Symbol.hasInstance](value: unknown): boolean;
|
||||
from(input: z.input<TSchema>): SchemaModel<TSchema, TPublicKey>;
|
||||
fromJson(json: unknown): SchemaModel<TSchema, TPublicKey>;
|
||||
}
|
||||
|
||||
/** Creates a shallow-frozen data class backed by a Zod schema. */
|
||||
export function createSchemaModel<
|
||||
TSchema extends z.ZodType,
|
||||
TPublicKey extends keyof z.output<TSchema> = never,
|
||||
>(schema: TSchema): SchemaModelFactory<TSchema, TPublicKey> {
|
||||
class GeneratedSchemaModel {
|
||||
private constructor(input: z.input<TSchema>) {
|
||||
Object.assign(this, schema.parse(input) as object);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(
|
||||
input: z.input<TSchema>,
|
||||
): SchemaModel<TSchema, TPublicKey> {
|
||||
return new GeneratedSchemaModel(input) as SchemaModel<
|
||||
TSchema,
|
||||
TPublicKey
|
||||
>;
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): SchemaModel<TSchema, TPublicKey> {
|
||||
return GeneratedSchemaModel.from(json as z.input<TSchema>);
|
||||
}
|
||||
|
||||
toJson(): z.output<TSchema> {
|
||||
return schema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
return GeneratedSchemaModel as unknown as SchemaModelFactory<
|
||||
TSchema,
|
||||
TPublicKey
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { UserEntitlements } from "@/data/schemas/user";
|
||||
import { User } from "@/data/schemas/user/user";
|
||||
|
||||
describe("User", () => {
|
||||
it("parses the latest backend user payload", () => {
|
||||
const user = User.fromJson({
|
||||
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
|
||||
username: "guest_bbc90a38",
|
||||
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
|
||||
platform: "web",
|
||||
country: "Hong Kong",
|
||||
countryCode: "HK",
|
||||
intimacy: 0,
|
||||
dolBalance: 0,
|
||||
creditBalance: 0,
|
||||
dailyFreeChatLimit: 30,
|
||||
dailyFreeChatUsed: 1,
|
||||
dailyFreeChatRemaining: 29,
|
||||
dailyFreePrivateLimit: 2,
|
||||
dailyFreePrivateUsed: 0,
|
||||
dailyFreePrivateRemaining: 2,
|
||||
dailyQuotas: {
|
||||
chat: {
|
||||
limit: 30,
|
||||
used: 1,
|
||||
remaining: 29,
|
||||
},
|
||||
privateMessage: {
|
||||
limit: 2,
|
||||
used: 0,
|
||||
remaining: 2,
|
||||
},
|
||||
},
|
||||
personalityTraits: {
|
||||
caring: 0.5,
|
||||
playful: 0.5,
|
||||
serious: 0.5,
|
||||
cheerful: 0.5,
|
||||
romantic: 0.5,
|
||||
},
|
||||
preferredLanguage: "zh",
|
||||
createdAt: "2026-06-12T08:08:27.378706+00:00",
|
||||
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||
});
|
||||
|
||||
expect(user.toJson()).toEqual({
|
||||
id: "d7bb2b3d-e9c5-474c-85fe-f503048f30dd",
|
||||
username: "guest_bbc90a38",
|
||||
email: "device_bbc90a38b996187348f54cf2cb0b789e@guest.local",
|
||||
platform: "web",
|
||||
country: "Hong Kong",
|
||||
countryCode: "HK",
|
||||
fbAsid: "",
|
||||
fbPsid: "",
|
||||
intimacy: 0,
|
||||
dolBalance: 0,
|
||||
creditBalance: 0,
|
||||
dailyFreeChatLimit: 30,
|
||||
dailyFreeChatUsed: 1,
|
||||
dailyFreeChatRemaining: 29,
|
||||
dailyFreePrivateLimit: 2,
|
||||
dailyFreePrivateUsed: 0,
|
||||
dailyFreePrivateRemaining: 2,
|
||||
personalityTraits: {
|
||||
caring: 0.5,
|
||||
playful: 0.5,
|
||||
serious: 0.5,
|
||||
cheerful: 0.5,
|
||||
romantic: 0.5,
|
||||
},
|
||||
preferredLanguage: "zh",
|
||||
createdAt: "2026-06-12T08:08:27.378706+00:00",
|
||||
lastMessageAt: "2026-06-24T11:00:23.156561+00:00",
|
||||
});
|
||||
|
||||
expect("dailyQuotas" in user.toJson()).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults nullable nested user fields through schema helpers", () => {
|
||||
const user = User.fromJson({
|
||||
id: "user-1",
|
||||
username: "guest_user",
|
||||
personalityTraits: null,
|
||||
});
|
||||
|
||||
expect(user.personalityTraits.toJson()).toEqual({
|
||||
caring: 0.5,
|
||||
playful: 0.5,
|
||||
serious: 0.5,
|
||||
cheerful: 0.5,
|
||||
romantic: 0.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses the latest backend entitlements payload", () => {
|
||||
const entitlements = UserEntitlements.fromJson({
|
||||
userId: "user-1",
|
||||
isGuest: false,
|
||||
isVip: true,
|
||||
vipExpiresAt: "2026-07-26T00:00:00+00:00",
|
||||
creditBalance: 120,
|
||||
dolBalance: 120,
|
||||
policy: {
|
||||
membershipState: "vip",
|
||||
nonVipEntitlementsShared: true,
|
||||
guestSameAsLoggedInNonVip: true,
|
||||
refreshAfterPayment: "GET /api/user/entitlements",
|
||||
},
|
||||
costs: {
|
||||
normal_message: 2,
|
||||
private_message: 10,
|
||||
voice_message: 20,
|
||||
photo: 40,
|
||||
voice_call_minute: 50,
|
||||
private_album_10: 300,
|
||||
private_album_20: 600,
|
||||
},
|
||||
quotas: {
|
||||
normalChatFreeDaily: 30,
|
||||
privateUnlockFreeDaily: 1,
|
||||
voiceMessageFreeDaily: 0,
|
||||
photoFreeDaily: 0,
|
||||
},
|
||||
historyUnlock: {
|
||||
enabled: true,
|
||||
order: "oldest_first",
|
||||
chargeMode: "highest_cost_per_locked_message",
|
||||
insufficientBalanceBehavior: "no_deduction",
|
||||
costs: {
|
||||
private_message: 10,
|
||||
voice_message: 20,
|
||||
photo: 40,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(entitlements.isVip).toBe(true);
|
||||
expect(entitlements.creditBalance).toBe(120);
|
||||
expect(entitlements.historyUnlock.costs.photo).toBe(40);
|
||||
});
|
||||
|
||||
it("defaults nullable entitlement sections through schema helpers", () => {
|
||||
const entitlements = UserEntitlements.fromJson({
|
||||
userId: "user-1",
|
||||
policy: null,
|
||||
costs: null,
|
||||
quotas: null,
|
||||
historyUnlock: null,
|
||||
});
|
||||
|
||||
expect(entitlements.policy.membershipState).toBe("");
|
||||
expect(entitlements.costs.private_message).toBe(0);
|
||||
expect(entitlements.quotas.normalChatFreeDaily).toBe(0);
|
||||
expect(entitlements.historyUnlock).toMatchObject({
|
||||
enabled: false,
|
||||
order: "",
|
||||
chargeMode: "",
|
||||
insufficientBalanceBehavior: "",
|
||||
costs: {
|
||||
private_message: 0,
|
||||
voice_message: 0,
|
||||
photo: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,3 +13,26 @@ export const AvatarDataSchema = z.object({
|
||||
|
||||
export type AvatarDataInput = z.input<typeof AvatarDataSchema>;
|
||||
export type AvatarDataData = z.output<typeof AvatarDataSchema>;
|
||||
|
||||
export class AvatarData {
|
||||
declare readonly avatarUrl: string;
|
||||
declare readonly userId: string;
|
||||
|
||||
private constructor(input: AvatarDataInput) {
|
||||
const data = AvatarDataSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: AvatarDataInput): AvatarData {
|
||||
return new AvatarData(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): AvatarData {
|
||||
return AvatarData.from(json as AvatarDataInput);
|
||||
}
|
||||
|
||||
toJson(): AvatarDataData {
|
||||
return AvatarDataSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,3 +24,29 @@ export const PersonalityTraitsSchema = z.object({
|
||||
|
||||
export type PersonalityTraitsInput = z.input<typeof PersonalityTraitsSchema>;
|
||||
export type PersonalityTraitsData = z.output<typeof PersonalityTraitsSchema>;
|
||||
|
||||
export class PersonalityTraits {
|
||||
declare readonly cheerful: number;
|
||||
declare readonly caring: number;
|
||||
declare readonly playful: number;
|
||||
declare readonly serious: number;
|
||||
declare readonly romantic: number;
|
||||
|
||||
private constructor(input: PersonalityTraitsInput) {
|
||||
const data = PersonalityTraitsSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: PersonalityTraitsInput): PersonalityTraits {
|
||||
return new PersonalityTraits(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): PersonalityTraits {
|
||||
return PersonalityTraits.from(json as PersonalityTraitsInput);
|
||||
}
|
||||
|
||||
toJson(): PersonalityTraitsData {
|
||||
return PersonalityTraitsSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,3 +15,28 @@ export const RecentMemorySchema = z.object({
|
||||
|
||||
export type RecentMemoryInput = z.input<typeof RecentMemorySchema>;
|
||||
export type RecentMemoryData = z.output<typeof RecentMemorySchema>;
|
||||
|
||||
export class RecentMemory {
|
||||
declare readonly type: string;
|
||||
declare readonly content: string;
|
||||
declare readonly importance: number;
|
||||
declare readonly createdAt: string;
|
||||
|
||||
private constructor(input: RecentMemoryInput) {
|
||||
const data = RecentMemorySchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: RecentMemoryInput): RecentMemory {
|
||||
return new RecentMemory(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): RecentMemory {
|
||||
return RecentMemory.from(json as RecentMemoryInput);
|
||||
}
|
||||
|
||||
toJson(): RecentMemoryData {
|
||||
return RecentMemorySchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
*
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { PersonalityTraitsSchema, PERSONALITY_TRAITS_DEFAULTS } from "./personality_traits";
|
||||
import {
|
||||
PersonalityTraits,
|
||||
PersonalityTraitsSchema,
|
||||
PERSONALITY_TRAITS_DEFAULTS,
|
||||
} from "./personality_traits";
|
||||
import {
|
||||
numberOrZero,
|
||||
schemaOr,
|
||||
@@ -37,3 +41,58 @@ export const UserSchema = z.object({
|
||||
|
||||
export type UserInput = z.input<typeof UserSchema>;
|
||||
export type UserData = z.output<typeof UserSchema>;
|
||||
|
||||
export class User {
|
||||
declare readonly id: string;
|
||||
declare readonly username: string;
|
||||
declare readonly email: string;
|
||||
declare readonly platform: string;
|
||||
declare readonly country: string;
|
||||
declare readonly countryCode: string;
|
||||
declare readonly fbAsid: string;
|
||||
declare readonly fbPsid: string;
|
||||
declare readonly intimacy: number;
|
||||
declare readonly dolBalance: number;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly dailyFreeChatLimit: number;
|
||||
declare readonly dailyFreeChatUsed: number;
|
||||
declare readonly dailyFreeChatRemaining: number;
|
||||
declare readonly dailyFreePrivateLimit: number;
|
||||
declare readonly dailyFreePrivateUsed: number;
|
||||
declare readonly dailyFreePrivateRemaining: number;
|
||||
declare readonly personalityTraits: PersonalityTraits;
|
||||
declare readonly preferredLanguage: string;
|
||||
declare readonly createdAt: string;
|
||||
declare readonly lastMessageAt: string;
|
||||
|
||||
private constructor(input: UserInput) {
|
||||
const data = UserSchema.parse(input);
|
||||
Object.assign(this, {
|
||||
...data,
|
||||
personalityTraits: PersonalityTraits.from(data.personalityTraits),
|
||||
});
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UserInput): User {
|
||||
return new User(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): User {
|
||||
return User.from(json as UserInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建一个具有空 id/username 的 User 实例(用于可选 user 字段的默认占位)
|
||||
*/
|
||||
static empty(): User {
|
||||
return new User({ id: "", username: "" });
|
||||
}
|
||||
|
||||
toJson(): UserData {
|
||||
return UserSchema.parse({
|
||||
...this,
|
||||
personalityTraits: this.personalityTraits.toJson(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,34 @@ export const UserEntitlementsSchema = z.object({
|
||||
|
||||
export type UserEntitlementsInput = z.input<typeof UserEntitlementsSchema>;
|
||||
export type UserEntitlementsData = z.output<typeof UserEntitlementsSchema>;
|
||||
|
||||
export class UserEntitlements {
|
||||
declare readonly userId: string;
|
||||
declare readonly isGuest: boolean;
|
||||
declare readonly isVip: boolean;
|
||||
declare readonly vipExpiresAt: string | null;
|
||||
declare readonly creditBalance: number;
|
||||
declare readonly dolBalance: number;
|
||||
declare readonly policy: UserEntitlementsData["policy"];
|
||||
declare readonly costs: UserEntitlementsData["costs"];
|
||||
declare readonly quotas: UserEntitlementsData["quotas"];
|
||||
declare readonly historyUnlock: UserEntitlementsData["historyUnlock"];
|
||||
|
||||
private constructor(input: UserEntitlementsInput) {
|
||||
const data = UserEntitlementsSchema.parse(input);
|
||||
Object.assign(this, data);
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static from(input: UserEntitlementsInput): UserEntitlements {
|
||||
return new UserEntitlements(input);
|
||||
}
|
||||
|
||||
static fromJson(json: unknown): UserEntitlements {
|
||||
return UserEntitlements.from(json as UserEntitlementsInput);
|
||||
}
|
||||
|
||||
toJson(): UserEntitlementsData {
|
||||
return UserEntitlementsSchema.parse(this);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user