133 lines
4.5 KiB
TypeScript
133 lines
4.5 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const expenseStatusSchema = z.enum(["unreimbursed", "reimbursed"]);
|
|
export type ExpenseStatus = z.infer<typeof expenseStatusSchema>;
|
|
|
|
export const attachmentKindSchema = z.enum(["payment_proof", "invoice"]);
|
|
export type AttachmentKind = z.infer<typeof attachmentKindSchema>;
|
|
|
|
export const MAX_AMOUNT_CENTS = 999_999_999_999;
|
|
|
|
const expenseFieldsSchema = z.object({
|
|
paidAt: z.string().datetime({ offset: true }),
|
|
amount: z.string().regex(/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/).refine((value) => {
|
|
try { amountToCents(value); return true; } catch { return false; }
|
|
}, "金额超出允许范围"),
|
|
note: z.string().trim().max(2000).default(""),
|
|
}).strict();
|
|
|
|
const invoiceMissingReasonField = z.string().trim().max(500).nullable().optional();
|
|
|
|
export const expenseInputSchema = expenseFieldsSchema.extend({
|
|
invoiceMissingReason: invoiceMissingReasonField.default(null),
|
|
}).strict();
|
|
|
|
export const expenseUpdateSchema = expenseFieldsSchema.extend({
|
|
invoiceMissingReason: invoiceMissingReasonField,
|
|
version: z.number().int().positive(),
|
|
}).strict();
|
|
|
|
export const statusUpdateSchema = z.object({
|
|
status: expenseStatusSchema,
|
|
version: z.number().int().positive(),
|
|
}).strict();
|
|
|
|
export const versionSchema = z.object({
|
|
version: z.number().int().positive(),
|
|
}).strict();
|
|
|
|
export const attachmentDeleteSchema = versionSchema.extend({
|
|
// Only needed when removing the final invoice. The server preserves an
|
|
// existing reason when this field is omitted.
|
|
invoiceMissingReason: invoiceMissingReasonField,
|
|
}).strict();
|
|
|
|
export const permanentDeleteSchema = versionSchema.extend({
|
|
password: z.string().min(1).max(512),
|
|
});
|
|
|
|
export const loginSchema = z.object({
|
|
username: z.string().trim().min(1).max(128),
|
|
password: z.string().min(1).max(512),
|
|
}).strict();
|
|
|
|
export const changePasswordSchema = z.object({
|
|
currentPassword: z.string().min(1).max(512),
|
|
newPassword: z.string().min(12).max(128),
|
|
}).strict();
|
|
|
|
export const createAdminSchema = z.object({
|
|
username: z.string().trim().min(3).max(64),
|
|
displayName: z.string().trim().min(1).max(80),
|
|
}).strict();
|
|
|
|
export const adminStatusSchema = z.object({
|
|
status: z.enum(["active", "disabled"]),
|
|
version: z.number().int().positive(),
|
|
}).strict();
|
|
|
|
export const exportRequestSchema = z.union([
|
|
z.object({
|
|
ids: z.array(z.string().uuid()).min(1).max(5000).refine((ids) => new Set(ids).size === ids.length, "记录不能重复"),
|
|
includeManifest: z.boolean().default(false),
|
|
}).strict(),
|
|
z.object({
|
|
month: z.string().regex(/^\d{4}-(?:0[1-9]|1[0-2])$/),
|
|
status: expenseStatusSchema,
|
|
query: z.string().max(200).default(""),
|
|
missingInvoice: z.boolean().default(false),
|
|
includeManifest: z.boolean().default(false),
|
|
}).strict(),
|
|
]);
|
|
|
|
export const updateJobStatusSchema = z.enum([
|
|
"queued",
|
|
"downloading",
|
|
"verifying",
|
|
"staged",
|
|
"backing_up",
|
|
"applying",
|
|
"completed",
|
|
"failed",
|
|
"cancelled",
|
|
]);
|
|
export type UpdateJobStatus = z.infer<typeof updateJobStatusSchema>;
|
|
|
|
export const updateOperationSchema = z.enum(["download", "apply"]);
|
|
export type UpdateOperation = z.infer<typeof updateOperationSchema>;
|
|
|
|
/** The browser never supplies release URLs or filesystem paths. */
|
|
export const updateApplySchema = z.object({
|
|
// Keep the browser contract aligned with server/update.ts' SemVer parser,
|
|
// including optional prerelease and build metadata segments.
|
|
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
|
confirm: z.literal(true),
|
|
jobId: z.string().uuid().optional(),
|
|
}).strict();
|
|
|
|
export const updateDownloadSchema = z.object({
|
|
version: z.string().trim().regex(/^v?(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/),
|
|
confirm: z.literal(true),
|
|
}).strict();
|
|
|
|
export type ApiError = {
|
|
error: {
|
|
code: string;
|
|
message: string;
|
|
requestId: string;
|
|
details?: unknown;
|
|
};
|
|
};
|
|
|
|
export function amountToCents(value: string): number {
|
|
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(value);
|
|
if (!match) throw new Error("INVALID_AMOUNT");
|
|
const cents = Number(match[1]) * 100 + Number((match[2] ?? "").padEnd(2, "0"));
|
|
if (!Number.isSafeInteger(cents) || cents <= 0 || cents > MAX_AMOUNT_CENTS) throw new Error("INVALID_AMOUNT");
|
|
return cents;
|
|
}
|
|
|
|
export function centsToAmount(cents: number): string {
|
|
return (cents / 100).toFixed(2);
|
|
}
|