feat: wave 2 migration (P3, P5, P7 openspec changes)
Implements, verifies, and archives the three remaining Wave 2 changes from openspec/MIGRATION-PLAN.md. - add-wallet-settlement (P3): demo recharge, guarded withdrawal freeze and one-time admin review, paginated own fund entries, idempotent per-shop weekly/monthly settlement statements with commission rate and one-time payout confirmation. - add-merchant-onboarding (P5): personal/enterprise applications with one live application per user, guarded review with mandatory rejection reason, and transactional shop + owner provisioning returning one-time credentials; mall onboarding/status pages and an admin review console. - add-membership-messaging (P7): platform member levels, append-only growth accrual on order completion with guarded one-way leveling, order/shipment/ refund system messages with unread/read state and soft deletion, plus the mall header unread badge. Backend: migrations 0019-0023, new wallet, settlement, merchant_onboarding, membership and messaging modules, event hooks in order/fulfillment/aftersale, and integration suites for each. Shared contract extended and all three frontends updated; code indexes, domain docs, backend guidelines and the migration tracker synced. Verification: cargo test -p vmall-api green twice consecutively; mall, admin and shop-admin builds pass; browser smoke on every new surface; openspec validate --all --strict green (33 passed). The three changes share the @vmall/shared contract, the mall mock adapter and per-app locale/nav files, so they are committed together to keep every commit buildable.
This commit is contained in:
@@ -27,6 +27,7 @@ import type {
|
||||
FlashSaleItemInput,
|
||||
FlashSaleSession,
|
||||
FlashSaleSessionInput,
|
||||
GenerateSettlementBody,
|
||||
GroupBuyIntent,
|
||||
GroupBuyingActivity,
|
||||
GroupBuyingActivityInput,
|
||||
@@ -38,6 +39,18 @@ import type {
|
||||
Invoice,
|
||||
InvoiceKind,
|
||||
LocalizedText,
|
||||
MarkAllReadResult,
|
||||
MemberLevel,
|
||||
MemberLevelInput,
|
||||
MembershipStatus,
|
||||
MerchantApplication,
|
||||
MerchantApplicationInput,
|
||||
MerchantApplicationQuery,
|
||||
MerchantApprovalResult,
|
||||
Message,
|
||||
MessageDeleteResult,
|
||||
MessageListQuery,
|
||||
GrowthLogEntry,
|
||||
Order,
|
||||
OrderStatus,
|
||||
Paged,
|
||||
@@ -49,8 +62,12 @@ import type {
|
||||
ReviewInput,
|
||||
ReviewSummary,
|
||||
ReviewableItem,
|
||||
CommissionRate,
|
||||
FreightTemplate,
|
||||
FreightTemplateInput,
|
||||
SettlementStatement,
|
||||
SettlementStatementDetail,
|
||||
SettlementStatementQuery,
|
||||
Shipment,
|
||||
ShippingCompany,
|
||||
ShippingQuote,
|
||||
@@ -60,7 +77,15 @@ import type {
|
||||
ShopProfileInput,
|
||||
ShopProfileSelfInput,
|
||||
Sku,
|
||||
UnreadCount,
|
||||
User,
|
||||
WalletEntry,
|
||||
WalletRechargeResult,
|
||||
WalletSummary,
|
||||
WalletWithdrawal,
|
||||
WithdrawalAccountDetails,
|
||||
WithdrawalReviewOutcome,
|
||||
WithdrawalStatus,
|
||||
} from "./types";
|
||||
|
||||
export interface ApiClientOptions {
|
||||
@@ -272,6 +297,34 @@ export interface ApiClient {
|
||||
listFlashSales(): Promise<PublicFlashSaleSession[]>;
|
||||
/** Public: active group-buying activities with their open groups. */
|
||||
listGroupBuyingActivities(): Promise<GroupBuyingActivityView[]>;
|
||||
/** Available/frozen balances of the caller's monetary account. */
|
||||
getWallet(): Promise<WalletSummary>;
|
||||
/** The caller's own ledger entries, newest first. */
|
||||
listWalletEntries(page?: number): Promise<Paged<WalletEntry>>;
|
||||
/** Simulated recharge; no external payment channel is involved. */
|
||||
rechargeWallet(amountMinor: number): Promise<WalletRechargeResult>;
|
||||
/** Freezes the amount in the caller's wallet pending platform review. */
|
||||
applyWithdrawal(
|
||||
amountMinor: number,
|
||||
details: WithdrawalAccountDetails,
|
||||
): Promise<WalletWithdrawal>;
|
||||
listMyWithdrawals(): Promise<WalletWithdrawal[]>;
|
||||
/** Merchant onboarding: one live application per user, else a 409. */
|
||||
submitMerchantApplication(body: MerchantApplicationInput): Promise<MerchantApplication>;
|
||||
/** The caller's own application history, newest first. */
|
||||
getMyMerchantApplications(): Promise<MerchantApplication[]>;
|
||||
/** Membership: derived level, growth total, and the next threshold. */
|
||||
getMembership(): Promise<MembershipStatus>;
|
||||
/** The caller's own growth ledger, newest first. */
|
||||
listGrowthLogs(page?: number): Promise<Paged<GrowthLogEntry>>;
|
||||
/** The caller's own system messages, newest first. */
|
||||
listMessages(q?: MessageListQuery): Promise<Paged<Message>>;
|
||||
/** Marks one owned message read; a repeat is a no-op. */
|
||||
markMessageRead(id: string): Promise<Message>;
|
||||
markAllMessagesRead(): Promise<MarkAllReadResult>;
|
||||
/** Soft-deletes one owned message; a repeat is a no-op. */
|
||||
deleteMessage(id: string): Promise<MessageDeleteResult>;
|
||||
getUnreadCount(): Promise<UnreadCount>;
|
||||
shop: {
|
||||
getMyShop(): Promise<Shop>;
|
||||
/** Merchant self-write of the own shop profile; scores stay platform-owned. */
|
||||
@@ -327,6 +380,11 @@ export interface ApiClient {
|
||||
deleteFreightTemplate(id: string): Promise<void>;
|
||||
listReviews(page?: number): Promise<Paged<Review>>;
|
||||
replyReview(id: string, content: LocalizedText): Promise<Review>;
|
||||
listShopSettlementStatements(
|
||||
q?: SettlementStatementQuery,
|
||||
): Promise<Paged<SettlementStatement>>;
|
||||
getShopSettlementStatement(id: string): Promise<SettlementStatementDetail>;
|
||||
generateShopSettlementStatement(body: GenerateSettlementBody): Promise<SettlementStatement>;
|
||||
};
|
||||
admin: {
|
||||
listUsers(page?: number): Promise<Paged<User>>;
|
||||
@@ -351,6 +409,36 @@ export interface ApiClient {
|
||||
listReviews(page?: number): Promise<Paged<Review>>;
|
||||
hideReview(id: string): Promise<Review>;
|
||||
deleteReview(id: string): Promise<void>;
|
||||
/** Withdrawal applications, optionally filtered by review status. */
|
||||
listWithdrawalApplications(status?: WithdrawalStatus): Promise<WalletWithdrawal[]>;
|
||||
/** Reviews a pending application exactly once; a repeat is a 409. */
|
||||
reviewWithdrawal(
|
||||
id: string,
|
||||
outcome: WithdrawalReviewOutcome,
|
||||
note?: string | null,
|
||||
): Promise<WalletWithdrawal>;
|
||||
getCommissionRate(): Promise<CommissionRate>;
|
||||
setCommissionRate(commissionRateBps: number): Promise<CommissionRate>;
|
||||
listSettlementStatements(
|
||||
q?: SettlementStatementQuery,
|
||||
): Promise<Paged<SettlementStatement>>;
|
||||
getSettlementStatement(id: string): Promise<SettlementStatementDetail>;
|
||||
/** Idempotent for a shop + period; a repeat returns the existing row. */
|
||||
generateSettlementStatement(body: GenerateSettlementBody): Promise<SettlementStatement>;
|
||||
confirmSettlementStatement(id: string): Promise<SettlementStatement>;
|
||||
listMerchantApplications(
|
||||
q?: MerchantApplicationQuery,
|
||||
): Promise<Paged<MerchantApplication>>;
|
||||
getMerchantApplication(id: string): Promise<MerchantApplication>;
|
||||
/** Provisions shop + owner in one transaction; credentials returned once. */
|
||||
approveMerchantApplication(id: string): Promise<MerchantApprovalResult>;
|
||||
rejectMerchantApplication(id: string, reason: string): Promise<MerchantApplication>;
|
||||
/** Member levels in growth-threshold order. */
|
||||
listMemberLevels(): Promise<MemberLevel[]>;
|
||||
createMemberLevel(body: MemberLevelInput): Promise<MemberLevel>;
|
||||
updateMemberLevel(id: string, body: MemberLevelInput): Promise<MemberLevel>;
|
||||
/** Rejected with a 409 when any customer currently holds the level. */
|
||||
deleteMemberLevel(id: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -427,6 +515,25 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
redeemPoints: (body) => r("POST", "/points/redemptions", body),
|
||||
listFlashSales: () => r("GET", "/flash-sales"),
|
||||
listGroupBuyingActivities: () => r("GET", "/group-buying/activities"),
|
||||
getWallet: () => r("GET", "/wallet"),
|
||||
listWalletEntries: (page = 1) => r("GET", "/wallet/entries", undefined, { page }),
|
||||
rechargeWallet: (amountMinor) =>
|
||||
r("POST", "/wallet/recharges", { amount_minor: amountMinor }),
|
||||
applyWithdrawal: (amountMinor, details) =>
|
||||
r("POST", "/wallet/withdrawals", {
|
||||
amount_minor: amountMinor,
|
||||
account_details: details,
|
||||
}),
|
||||
listMyWithdrawals: () => r("GET", "/wallet/withdrawals"),
|
||||
submitMerchantApplication: (body) => r("POST", "/merchant/applications", body),
|
||||
getMyMerchantApplications: () => r("GET", "/merchant/applications"),
|
||||
getMembership: () => r("GET", "/membership"),
|
||||
listGrowthLogs: (page = 1) => r("GET", "/membership/growth-logs", undefined, { page }),
|
||||
listMessages: (q = {}) => r("GET", "/messages", undefined, { ...q }),
|
||||
markMessageRead: (id) => r("POST", `/messages/${id}/read`),
|
||||
markAllMessagesRead: () => r("POST", "/messages/read-all"),
|
||||
deleteMessage: (id) => r("DELETE", `/messages/${id}`),
|
||||
getUnreadCount: () => r("GET", "/messages/unread-count"),
|
||||
shop: {
|
||||
getMyShop: () => r("GET", "/shop/profile"),
|
||||
updateMyProfile: (body) => r("PUT", "/shop/profile", body),
|
||||
@@ -480,6 +587,11 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
deleteFreightTemplate: (id) => r("DELETE", `/shop/freight-templates/${id}`),
|
||||
listReviews: (page = 1) => r("GET", "/shop/reviews", undefined, { page }),
|
||||
replyReview: (id, content) => r("POST", `/shop/reviews/${id}/reply`, { content }),
|
||||
listShopSettlementStatements: (q = {}) =>
|
||||
r("GET", "/shop/settlement/statements", undefined, { ...q }),
|
||||
getShopSettlementStatement: (id) => r("GET", `/shop/settlement/statements/${id}`),
|
||||
generateShopSettlementStatement: (body) =>
|
||||
r("POST", "/shop/settlement/statements", body),
|
||||
},
|
||||
admin: {
|
||||
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
|
||||
@@ -516,6 +628,32 @@ export function createApi(opts: ApiClientOptions): ApiClient {
|
||||
listPointsRedemptions: (page = 1) => r("GET", "/admin/points/orders", undefined, { page }),
|
||||
fulfillRedemption: (id) => r("POST", `/admin/points/orders/${id}/fulfill`),
|
||||
cancelRedemption: (id) => r("POST", `/admin/points/orders/${id}/cancel`),
|
||||
listWithdrawalApplications: (status) =>
|
||||
r("GET", "/admin/wallet/withdrawals", undefined, { status }),
|
||||
reviewWithdrawal: (id, outcome, note) =>
|
||||
r("POST", `/admin/wallet/withdrawals/${id}/review`, { outcome, note: note ?? null }),
|
||||
getCommissionRate: () => r("GET", "/admin/settlement/commission-rate"),
|
||||
setCommissionRate: (commissionRateBps) =>
|
||||
r("PUT", "/admin/settlement/commission-rate", {
|
||||
commission_rate_bps: commissionRateBps,
|
||||
}),
|
||||
listSettlementStatements: (q = {}) =>
|
||||
r("GET", "/admin/settlement/statements", undefined, { ...q }),
|
||||
getSettlementStatement: (id) => r("GET", `/admin/settlement/statements/${id}`),
|
||||
generateSettlementStatement: (body) => r("POST", "/admin/settlement/statements", body),
|
||||
confirmSettlementStatement: (id) =>
|
||||
r("POST", `/admin/settlement/statements/${id}/confirm`),
|
||||
listMerchantApplications: (q = {}) =>
|
||||
r("GET", "/admin/merchant/applications", undefined, { ...q }),
|
||||
getMerchantApplication: (id) => r("GET", `/admin/merchant/applications/${id}`),
|
||||
approveMerchantApplication: (id) =>
|
||||
r("POST", `/admin/merchant/applications/${id}/approve`),
|
||||
rejectMerchantApplication: (id, reason) =>
|
||||
r("POST", `/admin/merchant/applications/${id}/reject`, { reason }),
|
||||
listMemberLevels: () => r("GET", "/admin/member-levels"),
|
||||
createMemberLevel: (body) => r("POST", "/admin/member-levels", body),
|
||||
updateMemberLevel: (id, body) => r("PUT", `/admin/member-levels/${id}`, body),
|
||||
deleteMemberLevel: (id) => r("DELETE", `/admin/member-levels/${id}`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -824,3 +824,310 @@ export interface ReviewInput {
|
||||
content: LocalizedText;
|
||||
images?: string[];
|
||||
}
|
||||
|
||||
// ---- wallet ----
|
||||
|
||||
/** Available + frozen minor units of the caller's monetary account currency. */
|
||||
export interface WalletSummary {
|
||||
available_minor: number;
|
||||
frozen_minor: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
/** Which balance bucket a ledger entry moved. */
|
||||
export type WalletAccountKind = "available" | "frozen";
|
||||
|
||||
/** One append-only ledger entry mapped for the wallet, newest first. */
|
||||
export interface WalletEntry {
|
||||
id: string;
|
||||
account_kind: WalletAccountKind;
|
||||
/** Signed minor units; negative for money leaving the account. */
|
||||
delta_minor: number;
|
||||
/** Balance of that bucket after the movement. */
|
||||
balance_minor: number;
|
||||
reason: string;
|
||||
reference_type: string | null;
|
||||
reference_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Payout destination captured with a withdrawal application. */
|
||||
export interface WithdrawalAccountDetails {
|
||||
/** Payout channel, e.g. "bank" or "demo". */
|
||||
method: string;
|
||||
/** Destination identifier (masked account number or handle). */
|
||||
account: string;
|
||||
/** Optional account holder name. */
|
||||
holder?: string;
|
||||
}
|
||||
|
||||
export type WithdrawalStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface WalletWithdrawal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
/** Populated for the platform review queue; empty on the caller's own rows. */
|
||||
user_email: string;
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
account_details: WithdrawalAccountDetails;
|
||||
status: WithdrawalStatus;
|
||||
review_note: string | null;
|
||||
reviewed_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a simulated recharge. `demo` is always true: no external payment
|
||||
* channel is contacted, and the UI must label the flow as simulated.
|
||||
*/
|
||||
export interface WalletRechargeResult {
|
||||
id: string;
|
||||
demo: boolean;
|
||||
amount_minor: number;
|
||||
currency: string;
|
||||
status: "credited" | "failed";
|
||||
/** Available balance after the credit. */
|
||||
available_minor: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type WithdrawalReviewOutcome = "approve" | "reject";
|
||||
|
||||
// ---- settlement ----
|
||||
|
||||
export type SettlementPeriodKind = "week" | "month";
|
||||
export type SettlementStatus = "pending" | "confirmed";
|
||||
|
||||
/** One immutable per-shop, per-period reconciliation snapshot. */
|
||||
export interface SettlementStatement {
|
||||
id: string;
|
||||
shop_id: string;
|
||||
shop_name: LocalizedText;
|
||||
/** Currency the snapshot amounts are expressed in. */
|
||||
currency: string;
|
||||
period_kind: SettlementPeriodKind;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
order_count: number;
|
||||
gross_minor: number;
|
||||
refund_minor: number;
|
||||
commission_rate_bps: number;
|
||||
commission_minor: number;
|
||||
payable_minor: number;
|
||||
status: SettlementStatus;
|
||||
confirmed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** One contributing confirmed-received order, converted to statement currency. */
|
||||
export interface SettlementOrderLine {
|
||||
order_id: string;
|
||||
order_no: string;
|
||||
/** Currency the order was placed in. */
|
||||
order_currency: string;
|
||||
gross_minor: number;
|
||||
refund_minor: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Statement detail: the snapshot plus its contributing orders. */
|
||||
export interface SettlementStatementDetail extends SettlementStatement {
|
||||
orders: SettlementOrderLine[];
|
||||
}
|
||||
|
||||
export interface SettlementStatementQuery {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
shop_id?: string;
|
||||
status?: SettlementStatus;
|
||||
}
|
||||
|
||||
/** Generation input; the period is normalized to its containing week/month. */
|
||||
export interface GenerateSettlementBody {
|
||||
shop_id?: string;
|
||||
period_kind: SettlementPeriodKind;
|
||||
/** Any date inside the target period (ISO `YYYY-MM-DD`). */
|
||||
period_start: string;
|
||||
}
|
||||
|
||||
export interface CommissionRate {
|
||||
commission_rate_bps: number;
|
||||
}
|
||||
|
||||
// ---- merchant onboarding ----
|
||||
|
||||
export type MerchantEntityType = "personal" | "enterprise";
|
||||
export type MerchantApplicationStatus = "pending" | "approved" | "rejected";
|
||||
|
||||
export interface MerchantContact {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
/** Qualification materials are URLs only; this MVP has no file storage. */
|
||||
export interface MerchantQualification {
|
||||
identity_document_url?: string;
|
||||
business_license_url?: string;
|
||||
business_license_no?: string;
|
||||
extra_materials?: string[];
|
||||
}
|
||||
|
||||
interface MerchantApplicationBase {
|
||||
/** One or more ids from the reference category tree. */
|
||||
category_ids: string[];
|
||||
contact: MerchantContact;
|
||||
}
|
||||
|
||||
export interface PersonalMerchantApplicationInput extends MerchantApplicationBase {
|
||||
entity_type: "personal";
|
||||
real_name: string;
|
||||
qualification: MerchantQualification & { identity_document_url: string };
|
||||
}
|
||||
|
||||
export interface EnterpriseMerchantApplicationInput extends MerchantApplicationBase {
|
||||
entity_type: "enterprise";
|
||||
company_name: string;
|
||||
qualification: MerchantQualification & {
|
||||
business_license_url: string;
|
||||
business_license_no: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Discriminated by `entity_type`; each kind requires its own fields. */
|
||||
export type MerchantApplicationInput =
|
||||
| PersonalMerchantApplicationInput
|
||||
| EnterpriseMerchantApplicationInput;
|
||||
|
||||
/** A submitted category resolved for display, in submission order. */
|
||||
export interface MerchantCategoryRef {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
}
|
||||
|
||||
export interface MerchantApplication {
|
||||
id: string;
|
||||
user_id: string;
|
||||
applicant_email: string;
|
||||
entity_type: MerchantEntityType;
|
||||
real_name: string | null;
|
||||
company_name: string | null;
|
||||
business_license_no: string | null;
|
||||
category_ids: string[];
|
||||
categories: MerchantCategoryRef[];
|
||||
contact: MerchantContact;
|
||||
qualification: MerchantQualification;
|
||||
status: MerchantApplicationStatus;
|
||||
rejection_reason: string | null;
|
||||
reviewed_at: string | null;
|
||||
/** Shop created by approval; null until then. */
|
||||
created_shop_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** One-time initial login for the shop owner created by approval. */
|
||||
export interface MerchantOwnerCredentials {
|
||||
email: string;
|
||||
initial_password: string;
|
||||
shop_id: string;
|
||||
shop_slug: string;
|
||||
}
|
||||
|
||||
/** Approval response; the initial password appears here and nowhere else. */
|
||||
export interface MerchantApprovalResult {
|
||||
application: MerchantApplication;
|
||||
credentials: MerchantOwnerCredentials;
|
||||
}
|
||||
|
||||
export interface MerchantApplicationQuery {
|
||||
status?: MerchantApplicationStatus;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
// ---- membership ----
|
||||
|
||||
export interface MemberLevel {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
icon: string;
|
||||
/** Growth value in whole base-currency units. */
|
||||
growth_threshold: number;
|
||||
benefits: LocalizedText;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MemberLevelInput {
|
||||
name: LocalizedText;
|
||||
icon: string;
|
||||
growth_threshold: number;
|
||||
benefits: LocalizedText;
|
||||
}
|
||||
|
||||
/** One append-only growth accrual, in whole base-currency units. */
|
||||
export interface GrowthLogEntry {
|
||||
id: string;
|
||||
delta: number;
|
||||
growth_total: number;
|
||||
reason: string;
|
||||
reference_type: string | null;
|
||||
reference_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface NextMemberLevel {
|
||||
id: string;
|
||||
name: LocalizedText;
|
||||
icon: string;
|
||||
growth_threshold: number;
|
||||
/** Growth still needed to reach the threshold. */
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
export interface MembershipStatus {
|
||||
/** Level re-derived from the growth total against current thresholds. */
|
||||
level: MemberLevel | null;
|
||||
growth_total: number;
|
||||
next_level: NextMemberLevel | null;
|
||||
}
|
||||
|
||||
// ---- messaging ----
|
||||
|
||||
export type MessageKind = "order_paid" | "order_shipped" | "refund_completed";
|
||||
export type MessageStatus = "unread" | "read";
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
kind: MessageKind;
|
||||
title: LocalizedText;
|
||||
body: LocalizedText;
|
||||
reference_type: string | null;
|
||||
reference_id: string | null;
|
||||
status: MessageStatus;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface MessageListQuery {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
unread_only?: boolean;
|
||||
}
|
||||
|
||||
export interface MarkAllReadResult {
|
||||
updated: number;
|
||||
}
|
||||
|
||||
export interface MessageDeleteResult {
|
||||
id: string;
|
||||
deleted: boolean;
|
||||
}
|
||||
|
||||
export interface UnreadCount {
|
||||
unread: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user