diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95561b6..9c63f6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,12 @@ jobs: - name: Version check run: pnpm version:check + - name: Migration numbering check + run: node scripts/check-migration-numbering.mjs + + - name: API interface catalog check + run: pnpm api-catalog:check + - name: Test run: pnpm test diff --git a/CHANGELOG.md b/CHANGELOG.md index 880fdca..1317d7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.10] - 2026-09-05 + +### Fixed + +- Scheduled the hourly retention cleanup on staging and production (it previously had no Cloudflare cron trigger, so expired messages and R2 attachments accumulated forever) and made the sweep index-backed, batched, and parallel for attachment deletes (D1 migration 0020). +- Stopped the cleanup and batch-delete paths from exceeding Cloudflare D1's 100 bound-parameter limit on large id lists, which previously made the hourly cleanup fail outright on any backlog over 100 messages. +- Cut every authenticated request from four D1 session round-trips to two by throttling last-seen updates to once per minute. +- Superseded inbox refreshes no longer surface "request aborted" errors: stale requests are cancelled (also on unmount), and callers sharing a deduplicated GET no longer inherit each other's aborts. +- Announcement keyword search in the D1 backend now matches the JS source of truth exactly — multi-word keywords across title/summary/tags, tags containing JSON punctuation, and literal `%` / `_` characters — and empty-string start/end dates are normalized so the board list and detail view agree (D1 migration 0021). + +### Changed + +- Announcement list filtering, pagination, and status summaries now run in D1 SQL instead of scanning the whole table into JS on every board render. +- Split the 2,341-line D1 persistence module, the 1,699-line form primitives module, and the 1,449-line webhook console into per-aggregate / per-widget modules with unchanged public entry points, verified byte-identical against the originals. +- CI now enforces migration numbering uniqueness and API interface catalog freshness on every pull request. + +### Security + +- Local development CORS origins are only honored when `ENVIRONMENT=local`; staging and production no longer unconditionally allow them. +- OAuth start/callback/finalize endpoints are now behind the shared rate limiter, like login and registration. + ## [0.2.9] - 2026-08-08 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md index dc27dc1..18037e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,12 +82,13 @@ Dependency direction: `app → pages → features → shared`. Pages must not de | Layer | Purpose | |---|---| -| `app/` | Route registration, request/response mapping, use case orchestration (`routes/`, `use-cases/`, `services/`, `mappers/`) | +| `app/` | Request middleware (auth/CORS/rate limit in `create-app.ts`), use case orchestration (`use-cases/`, `services/`, `mappers/`), runtime entry (`runtime.ts`); legacy `routes/` holds only shared DTO/request parsers | +| `modules/` | HTTP route registration per menu domain (`modules//routes.ts`) — wired by `modules/register-modules.ts` | | `core/` | Type contracts, Cloudflare bindings, context definitions — no implementations | | `infrastructure/` | D1 database, R2 storage, external service integrations | | `shared/` | Email parsing (postal-mime), security utilities, pure helpers | -Dependency direction: `app → core/infrastructure/shared`. Infrastructure must not depend on `app/`. +Dependency direction: `app → modules → core/infrastructure/shared`. Infrastructure must not depend on `app/`. ### Shared package (`packages/shared/`) @@ -130,9 +131,9 @@ Not allowed: DOM operations, Cloudflare bindings, database logic, runtime-specif - Backend runs on **Cloudflare Workers** (not Node.js) — no Node built-ins, use Web APIs. `nodejs_compat` flag is on, but prefer Web APIs - Database: **D1** (SQLite-compatible), Object storage: **R2** - Email inbound processing via Cloudflare Email Routing -- Scheduled cleanup tasks via Cloudflare Cron Triggers +- Scheduled cleanup tasks via Cloudflare Cron Triggers (`[env.*.triggers] crons = ["0 * * * *"]` in wrangler.toml, hourly) - Deploy environments are split in `apps/worker/wrangler.toml`: `default` (local), `env.staging`, `env.production` — each points at its own D1 instance and vars -- Feature flags live in wrangler vars (`ENABLE_AI`, `ENABLE_TELEGRAM`, `ENABLE_OUTBOUND`, `ENABLE_MAILBOX_CREATION`) and gate code paths; honor them when adding new behavior +- Feature flags (`ENABLE_AI`, `ENABLE_TELEGRAM`, `ENABLE_OUTBOUND`, `ENABLE_MAILBOX_CREATION`) are code-level *defaults* parsed from wrangler vars in `core/config.ts`; the live values come from D1 `system_settings` (edited in the admin UI, cached in KV). Gate new behavior behind the resolved feature toggles, not the raw env vars **Database migrations:** - Migration files live in `apps/worker/src/infrastructure/db/migrations/` diff --git a/apps/docs/package.json b/apps/docs/package.json index 3d08cad..9d2ffcc 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,6 +1,6 @@ { "name": "@wemail/docs", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "scripts": { diff --git a/apps/web/package.json b/apps/web/package.json index c9e3731..fc778b0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@wemail/web", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/features/inbox/api.ts b/apps/web/src/features/inbox/api.ts index ba907e6..5e89d30 100644 --- a/apps/web/src/features/inbox/api.ts +++ b/apps/web/src/features/inbox/api.ts @@ -85,7 +85,7 @@ function normalizeMessageListQuery(query?: MessageListQueryInput | string | null return query ?? {}; } -export function fetchMessages(query?: MessageListQueryInput | string | null) { +export function fetchMessages(query?: MessageListQueryInput | string | null, options?: { signal?: AbortSignal }) { const normalizedQuery = normalizeMessageListQuery(query); const params = new URLSearchParams(); const search = normalizedQuery.search?.trim(); @@ -102,7 +102,7 @@ export function fetchMessages(query?: MessageListQueryInput | string | null) { if (typeof normalizedQuery.hasAttachment === "boolean") params.set("hasAttachment", String(normalizedQuery.hasAttachment)); if (normalizedQuery.extractionType) params.set("extractionType", normalizedQuery.extractionType); - return apiFetch(`/api/mail/messages?${params.toString()}`); + return apiFetch(`/api/mail/messages?${params.toString()}`, { signal: options?.signal }); } export function fetchMessageDetail(messageId: string) { diff --git a/apps/web/src/features/inbox/queries.ts b/apps/web/src/features/inbox/queries.ts index 3d1616c..0771744 100644 --- a/apps/web/src/features/inbox/queries.ts +++ b/apps/web/src/features/inbox/queries.ts @@ -56,8 +56,11 @@ export async function queryMailboxOptions(query: MailboxListQueryInput): Promise }; } -export async function queryMessages(query?: MessageListQueryInput | string | null): Promise { - const payload = await fetchMessages(query); +export async function queryMessages( + query?: MessageListQueryInput | string | null, + options?: { signal?: AbortSignal } +): Promise { + const payload = await fetchMessages(query, options); const messages = (payload.messages ?? []) as MessageSummary[]; const summary = payload.summary ?? (messages.length > 0 ? summarizeMessages(messages) : emptyMessageSummary); diff --git a/apps/web/src/features/inbox/useInboxWorkspace.ts b/apps/web/src/features/inbox/useInboxWorkspace.ts index 4747026..4bb4434 100644 --- a/apps/web/src/features/inbox/useInboxWorkspace.ts +++ b/apps/web/src/features/inbox/useInboxWorkspace.ts @@ -104,6 +104,7 @@ export function useInboxWorkspace({ const [isLoadingOutbound, setIsLoadingOutbound] = useState(false); const [outboundError, setOutboundError] = useState(null); const messagesRequestIdRef = useRef(0); + const messagesAbortControllerRef = useRef(null); const messageDetailRequestIdRef = useRef(0); const selectedMessageIdRef = useRef(selectedMessageId); @@ -114,6 +115,14 @@ export function useInboxWorkspace({ selectedMessageIdRef.current = selectedMessageId; }, [selectedMessageId]); + // Unmount cancels the in-flight message request; the requestId guard already + // ignores late state updates, this also stops the network work itself. + useEffect(() => { + return () => { + messagesAbortControllerRef.current?.abort(); + }; + }, []); + const refreshMailboxes = useCallback( async (nextSelectedMailboxId?: string | null) => { if (!enabled) return; @@ -127,10 +136,17 @@ export function useInboxWorkspace({ async (query?: MessageListQueryInput | string | null) => { const requestId = messagesRequestIdRef.current + 1; messagesRequestIdRef.current = requestId; + // Cancel the superseded request so query changes and polling ticks do not + // leave stale responses racing the newest one. + messagesAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + messagesAbortControllerRef.current = abortController; setIsLoadingMessages(true); try { - const result = await queryMessages(normalizeMessageQuery(query, selectedMailboxId)); + const result = await queryMessages(normalizeMessageQuery(query, selectedMailboxId), { + signal: abortController.signal + }); if (messagesRequestIdRef.current !== requestId) return; const previousSelectedMessageId = selectedMessageIdRef.current; const nextSelectedMessageId = result.messages.some((message) => message.id === previousSelectedMessageId) @@ -147,6 +163,9 @@ export function useInboxWorkspace({ setMessageListError(null); } catch (error) { if (messagesRequestIdRef.current !== requestId) return; + // Aborts are never user-facing errors: either a newer refresh + // superseded this one, or the shared request was cancelled elsewhere. + if (abortController.signal.aborted || (error instanceof Error && error.name === "AbortError")) return; setMessageListError(error instanceof Error ? error.message : "邮件列表加载失败"); } finally { if (messagesRequestIdRef.current === requestId) setIsLoadingMessages(false); diff --git a/apps/web/src/features/settings/WebhookCodeBlock.tsx b/apps/web/src/features/settings/WebhookCodeBlock.tsx new file mode 100644 index 0000000..32e7d5c --- /dev/null +++ b/apps/web/src/features/settings/WebhookCodeBlock.tsx @@ -0,0 +1,34 @@ +import { Copy } from "lucide-react"; + +import { Button } from "../../shared/button"; + +export type WebhookCodeBlockProps = { + copied: boolean; + copyLabel: string; + label: string; + onCopy: () => void; + value: string; +}; + + +export function WebhookCodeBlock({ copied, copyLabel, label, onCopy, value }: WebhookCodeBlockProps) { + return ( +
+
+ {label} + +
+
+        {value}
+      
+
+ ); +} diff --git a/apps/web/src/features/settings/WebhookDeliveryDialog.tsx b/apps/web/src/features/settings/WebhookDeliveryDialog.tsx new file mode 100644 index 0000000..8acaaaa --- /dev/null +++ b/apps/web/src/features/settings/WebhookDeliveryDialog.tsx @@ -0,0 +1,91 @@ +import { RotateCw } from "lucide-react"; + +import { Button } from "../../shared/button"; +import { WebhookCodeBlock } from "./WebhookCodeBlock"; +import { OverlayDialog } from "../../shared/overlay"; +import { + formatDate, + formatJson, + getEventLabel, + isSuccessfulDelivery, + normalizeStatus, + type WebhookDeliveryDialogProps +} from "./webhook-content"; + +export function WebhookDeliveryDialog({ + copiedToken, + delivery, + isDeliveryLoading, + onClose, + onCopy, + onRetry +}: WebhookDeliveryDialogProps) { + return ( + + {!isSuccessfulDelivery(delivery) ? ( + + ) : null} + + + } + onClose={onClose} + size="lg" + title={getEventLabel(delivery.eventType)} + > +
+
+ 投递状态 + {normalizeStatus(delivery.status)} +
+
+ 状态码 + {delivery.statusCode ?? "无状态码"} +
+
+ 耗时 + {delivery.durationMs === null ? "未记录耗时" : `${delivery.durationMs} ms`} +
+
+ 创建时间 + {formatDate(delivery.createdAt)} +
+
+ {delivery.errorText ? ( +

+ {delivery.errorText} +

+ ) : null} +
+ void onCopy("delivery-payload", formatJson(delivery.payload))} + value={formatJson(delivery.payload)} + /> + void onCopy("delivery-response", delivery.responseText ?? "")} + value={delivery.responseText || "目标服务没有返回响应体。"} + /> +
+
+ ); +} diff --git a/apps/web/src/features/settings/WebhookEndpointDialog.tsx b/apps/web/src/features/settings/WebhookEndpointDialog.tsx new file mode 100644 index 0000000..5b3d4f8 --- /dev/null +++ b/apps/web/src/features/settings/WebhookEndpointDialog.tsx @@ -0,0 +1,137 @@ +import { useMemo } from "react"; +import { ListChecks } from "lucide-react"; + +import { Button } from "../../shared/button"; +import { CheckboxField, FormField, TextInput } from "../../shared/form"; +import { OverlayDialog } from "../../shared/overlay"; +import { webhookEventGroups, type WebhookEndpointDialogProps } from "./webhook-content"; + +export function WebhookEndpointDialog({ + createDraft, + errorMessage, + isCreateDraftValid, + isEditingEndpoint, + isSaving, + onDraftChange, + onClose, + onSubmit +}: WebhookEndpointDialogProps) { + const createDraftEventSet = useMemo(() => new Set(createDraft.events), [createDraft.events]); + + function handleToggleEvent(value: string, checked: boolean) { + // Set semantics, like the pre-split toggleCreateEvent: checking is + // idempotent (no duplicate ids) and unchecking removes every occurrence. + onDraftChange((current) => ({ + ...current, + events: checked + ? Array.from(new Set([...current.events, value])) + : current.events.filter((eventValue) => eventValue !== value) + })); + } + + return ( + + + + + } + onClose={onClose} + size="lg" + title={isEditingEndpoint ? "编辑端点" : "新增端点"} + > +
{ + event.preventDefault(); + onSubmit(); + }} + > + {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} + +
+ + onDraftChange((current) => ({ ...current, name: event.target.value }))} + placeholder="输入端点名称" + value={createDraft.name} + /> + + + onDraftChange((current) => ({ ...current, url: event.target.value }))} + placeholder="输入接收 Webhook 的 HTTPS 地址" + value={createDraft.url} + /> + +
+ + onDraftChange((current) => ({ ...current, enabled: event.target.checked }))} + variant="card" + /> + +
+
+ + 事件订阅 +
+
+ {webhookEventGroups.map((group) => ( +
+
+ {group.title} +

{group.description}

+
+
+ {group.events.map((event) => ( + {event.value}} + key={event.value} + label={event.label} + onChange={(changeEvent) => handleToggleEvent(event.value, changeEvent.target.checked)} + variant="card" + /> + ))} +
+
+ ))} +
+
+ +
+ ); +} diff --git a/apps/web/src/features/settings/WebhookPage.tsx b/apps/web/src/features/settings/WebhookPage.tsx index 9af25fc..6658169 100644 --- a/apps/web/src/features/settings/WebhookPage.tsx +++ b/apps/web/src/features/settings/WebhookPage.tsx @@ -26,279 +26,41 @@ import { useAppStore } from "../../app/appStore"; import { Button } from "../../shared/button"; import { apiFetch } from "../../shared/api/client"; import { CheckboxField, FormField, SelectInput, TextInput } from "../../shared/form"; -import { OverlayDialog } from "../../shared/overlay"; import { Pagination } from "../../shared/pagination"; -const WEBHOOK_ENDPOINT_PAGE_SIZE = 5; -const WEBHOOK_ENDPOINT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; -const WEBHOOK_DELIVERY_PAGE_SIZE = 5; -const WEBHOOK_DELIVERY_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; - -type WebhookDeliveryStatus = "all" | "success" | "failed"; - -const webhookDeliveryStatusOptions: Array<{ label: string; value: WebhookDeliveryStatus }> = [ - { label: "全部", value: "all" }, - { label: "成功", value: "success" }, - { label: "失败", value: "failed" } -]; - -const webhookEventGroups = [ - { - title: "邮件事件", - description: "收件、提取和失败都应该第一时间进入你的自动化链路。", - events: [ - { label: "新邮件到达", value: "message.received" }, - { label: "提取结果完成", value: "message.extracted" }, - { label: "邮件处理失败", value: "message.failed" } - ] - }, - { - title: "通知事件", - description: "用来观察外部提醒链路是否成功送达。", - events: [ - { label: "Telegram 发送成功", value: "telegram.sent" }, - { label: "Telegram 发送失败", value: "telegram.failed" } - ] - }, - { - title: "系统事件", - description: "安全和配置变化适合同步到审计系统。", - events: [ - { label: "API 密钥创建", value: "api_key.created" }, - { label: "API 密钥吊销", value: "api_key.revoked" }, - { label: "配置变更", value: "settings.updated" } - ] - } -] as const; - -const defaultWebhookEvents = webhookEventGroups[0].events.map((event) => event.value); -const notificationRuleEventOptions = [ - { label: "新邮件到达", value: "message.received" }, - { label: "Webhook 提取结果", value: "message.extracted" }, - { label: "Telegram 提取结果", value: "message.extraction.detected" }, - { label: "邮件处理失败", value: "message.failed" }, - { label: "Telegram 测试", value: "telegram.test" }, - { label: "API 密钥创建", value: "api_key.created" }, - { label: "API 密钥吊销", value: "api_key.revoked" }, - { label: "配置变更", value: "settings.updated" } -]; -const notificationTargetLabels: Record = { - webhook: "Webhook", - telegram: "Telegram", - slack: "Slack", - discord: "Discord", - feishu: "飞书", - wecom: "企业微信" -}; - -const sampleHeaders = [ - "content-type: application/json", - "user-agent: WeMail-Webhook/1.0", - "x-wemail-event: message.received", - "x-wemail-delivery-id: whd_01H...", - "x-wemail-signature: sha256=..." -].join("\n"); - -const samplePayload = JSON.stringify( - { - createdAt: "2026-04-17T11:30:00.000Z", - data: { - message: "WeMail webhook event", - messageId: "msg_01H...", - subject: "Your verification code" - }, - deliveryId: "whd_01H...", - endpoint: { - id: "whe_01H...", - name: "Production Sync" - }, - eventType: "message.received" - }, - null, - 2 -); - -const signatureVerifyExample = JSON.stringify( - { - input: "raw request body + Signing Secret", - compareWith: "x-wemail-signature", - note: "Secret stays on your server; the header only contains the sha256 signature." - }, - null, - 2 -); - -const signatureHelpItems = [ - "Signing Secret 只保存在 WeMail 和你的目标服务端,不会明文放进 Header。", - "WeMail 会用 Secret 对原始请求体计算 HMAC-SHA256,并把结果放到 x-wemail-signature。", - "目标服务收到请求后,用同一个 Secret 和原始 body 重新计算签名,再与 Header 比对。" -]; - -type WebhookEndpoint = { - id: string; - name: string; - url: string; - events: string[]; - signingSecret?: string; - enabled: boolean; - createdAt?: string; - updatedAt?: string; -}; - -type WebhookDelivery = { - id: string; - endpointId?: string; - eventType: string; - status: string; - statusCode: number | null; - durationMs: number | null; - errorText: string | null; - payload?: unknown; - responseText?: string | null; - createdAt: string; -}; - -type NotificationRuleDraft = { - enabled: boolean; - eventTypes: string[]; - keyword: string; - mailboxIds: string; - name: string; - quietHoursEnd: string; - quietHoursStart: string; - target: NotificationRuleTarget; - targetId: string; -}; - -type EndpointDraft = { - enabled: boolean; - events: string[]; - name: string; - url: string; -}; - -type OverviewTone = "accent" | "info" | "success" | "warning"; - -type WebhookCodeBlockProps = { - copied: boolean; - copyLabel: string; - label: string; - onCopy: () => void; - value: string; -}; - -type WebhookEndpointListPayload = { - endpoints?: WebhookEndpoint[]; - page?: number; - pageSize?: number; - total?: number; -}; - -type WebhookDeliveryListPayload = { - deliveries?: WebhookDelivery[]; - page?: number; - pageSize?: number; - total?: number; -}; - -type NotificationRuleListPayload = { - rules?: NotificationRuleSummary[]; -}; - -const emptyDraft: EndpointDraft = { - enabled: true, - events: defaultWebhookEvents, - name: "", - url: "" -}; - -const emptyNotificationRuleDraft: NotificationRuleDraft = { - enabled: true, - eventTypes: ["message.received"], - keyword: "", - mailboxIds: "", - name: "", - quietHoursEnd: "", - quietHoursStart: "", - target: "webhook", - targetId: "" -}; - -function readErrorMessage(error: unknown) { - if (error instanceof Error) return error.message; - if (typeof error === "string") return error; - return "Webhook 配置同步失败,请稍后重试。"; -} - -function formatDate(value?: string) { - if (!value) return "尚未记录"; - const timestamp = Date.parse(value); - if (!Number.isFinite(timestamp)) return value; - return new Intl.DateTimeFormat("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit" - }).format(new Date(timestamp)); -} - -function normalizeStatus(status: string) { - const value = status.toLowerCase(); - if (value === "success" || value === "delivered" || value === "ok") return "成功"; - if (value === "failed" || value === "error") return "失败"; - if (value === "pending" || value === "retrying") return "重试中"; - return status; -} - -function isSuccessfulDelivery(delivery: WebhookDelivery) { - const value = delivery.status.toLowerCase(); - return value === "success" || value === "delivered" || value === "ok"; -} - -function getEventLabel(value: string) { - for (const group of webhookEventGroups) { - const match = group.events.find((event) => event.value === value); - if (match) return match.label; - } - return value; -} - -function formatJson(value: unknown) { - if (typeof value === "undefined") return "{}"; - if (typeof value === "string") return value; - try { - return JSON.stringify(value, null, 2); - } catch { - return String(value); - } -} - -async function copyText(text: string) { - if (typeof navigator === "undefined" || !navigator.clipboard) return; - await navigator.clipboard.writeText(text); -} - -function WebhookCodeBlock({ copied, copyLabel, label, onCopy, value }: WebhookCodeBlockProps) { - return ( -
-
- {label} - -
-
-        {value}
-      
-
- ); -} +import { + copyText, + emptyDraft, + emptyNotificationRuleDraft, + formatDate, + getEventLabel, + isSuccessfulDelivery, + normalizeStatus, + notificationRuleEventOptions, + notificationTargetLabels, + readErrorMessage, + sampleHeaders, + samplePayload, + signatureHelpItems, + signatureVerifyExample, + webhookDeliveryStatusOptions, + WEBHOOK_DELIVERY_PAGE_SIZE, + WEBHOOK_DELIVERY_PAGE_SIZE_OPTIONS, + WEBHOOK_ENDPOINT_PAGE_SIZE, + WEBHOOK_ENDPOINT_PAGE_SIZE_OPTIONS, + type EndpointDraft, + type NotificationRuleDraft, + type NotificationRuleListPayload, + type OverviewTone, + type WebhookDelivery, + type WebhookDeliveryListPayload, + type WebhookDeliveryStatus, + type WebhookEndpoint, + type WebhookEndpointListPayload +} from "./webhook-content"; +import { WebhookCodeBlock } from "./WebhookCodeBlock"; +import { WebhookDeliveryDialog } from "./WebhookDeliveryDialog"; +import { WebhookEndpointDialog } from "./WebhookEndpointDialog"; export function WebhookPage() { const pushToast = useAppStore((state) => state.pushToast); @@ -425,7 +187,6 @@ export function WebhookPage() { [endpoints, selectedEndpointId] ); const expandedEndpointSet = useMemo(() => new Set(expandedEndpointIds), [expandedEndpointIds]); - const createDraftEventSet = useMemo(() => new Set(createDraft.events), [createDraft.events]); const latestDelivery = deliveries[0] ?? null; const enabledEndpointCount = endpoints.filter((endpoint) => endpoint.enabled).length; const selectedEventCount = selectedEndpoint?.events.length ?? 0; @@ -542,18 +303,6 @@ export function WebhookPage() { setIsCreateDialogOpen(true); } - function toggleCreateEvent(value: string, checked: boolean) { - setCreateDraft((current) => { - const events = new Set(current.events); - if (checked) { - events.add(value); - } else { - events.delete(value); - } - return { ...current, events: Array.from(events) }; - }); - } - function toggleNotificationRuleEvent(value: string, checked: boolean) { setNotificationRuleDraft((current) => { const eventTypes = new Set(current.eventTypes); @@ -1265,184 +1014,30 @@ export function WebhookPage() { {isCreateDialogOpen ? ( - - - - - } + { setIsCreateDialogOpen(false); setEditingEndpointId(null); }} - size="lg" - title={isEditingEndpoint ? "编辑端点" : "新增端点"} - > -
{ - event.preventDefault(); - void saveEndpoint(); - }} - > - {errorMessage ? ( -

- {errorMessage} -

- ) : null} - -
- - setCreateDraft((current) => ({ ...current, name: event.target.value }))} - placeholder="输入端点名称" - value={createDraft.name} - /> - - - setCreateDraft((current) => ({ ...current, url: event.target.value }))} - placeholder="输入接收 Webhook 的 HTTPS 地址" - value={createDraft.url} - /> - -
- - setCreateDraft((current) => ({ ...current, enabled: event.target.checked }))} - variant="card" - /> - -
-
- - 事件订阅 -
-
- {webhookEventGroups.map((group) => ( -
-
- {group.title} -

{group.description}

-
-
- {group.events.map((event) => ( - {event.value}} - key={event.value} - label={event.label} - onChange={(changeEvent) => toggleCreateEvent(event.value, changeEvent.target.checked)} - variant="card" - /> - ))} -
-
- ))} -
-
- -
+ onDraftChange={(update) => setCreateDraft((current) => update(current))} + onSubmit={() => void saveEndpoint()} + /> ) : null} {selectedDelivery ? ( - - {!isSuccessfulDelivery(selectedDelivery) ? ( - - ) : null} - - - } + setSelectedDelivery(null)} - size="lg" - title={getEventLabel(selectedDelivery.eventType)} - > -
-
- 投递状态 - {normalizeStatus(selectedDelivery.status)} -
-
- 状态码 - {selectedDelivery.statusCode ?? "无状态码"} -
-
- 耗时 - {selectedDelivery.durationMs === null ? "未记录耗时" : `${selectedDelivery.durationMs} ms`} -
-
- 创建时间 - {formatDate(selectedDelivery.createdAt)} -
-
- {selectedDelivery.errorText ? ( -

- {selectedDelivery.errorText} -

- ) : null} -
- void handleCopy("delivery-payload", formatJson(selectedDelivery.payload))} - value={formatJson(selectedDelivery.payload)} - /> - void handleCopy("delivery-response", selectedDelivery.responseText ?? "")} - value={selectedDelivery.responseText || "目标服务没有返回响应体。"} - /> -
-
+ onCopy={(token, text) => void handleCopy(token, text)} + onRetry={(delivery) => void handleRetryDelivery(delivery)} + /> ) : null} ); diff --git a/apps/web/src/features/settings/webhook-content.ts b/apps/web/src/features/settings/webhook-content.ts new file mode 100644 index 0000000..6a191a0 --- /dev/null +++ b/apps/web/src/features/settings/webhook-content.ts @@ -0,0 +1,263 @@ +import type { NotificationRuleSummary, NotificationRuleTarget } from "@wemail/shared"; + +export const WEBHOOK_ENDPOINT_PAGE_SIZE = 5; +export const WEBHOOK_ENDPOINT_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; +export const WEBHOOK_DELIVERY_PAGE_SIZE = 5; +export const WEBHOOK_DELIVERY_PAGE_SIZE_OPTIONS = [5, 10, 20, 50] as const; + +export type WebhookDeliveryStatus = "all" | "success" | "failed"; + +export const webhookDeliveryStatusOptions: Array<{ label: string; value: WebhookDeliveryStatus }> = [ + { label: "全部", value: "all" }, + { label: "成功", value: "success" }, + { label: "失败", value: "failed" } +]; + +export const webhookEventGroups = [ + { + title: "邮件事件", + description: "收件、提取和失败都应该第一时间进入你的自动化链路。", + events: [ + { label: "新邮件到达", value: "message.received" }, + { label: "提取结果完成", value: "message.extracted" }, + { label: "邮件处理失败", value: "message.failed" } + ] + }, + { + title: "通知事件", + description: "用来观察外部提醒链路是否成功送达。", + events: [ + { label: "Telegram 发送成功", value: "telegram.sent" }, + { label: "Telegram 发送失败", value: "telegram.failed" } + ] + }, + { + title: "系统事件", + description: "安全和配置变化适合同步到审计系统。", + events: [ + { label: "API 密钥创建", value: "api_key.created" }, + { label: "API 密钥吊销", value: "api_key.revoked" }, + { label: "配置变更", value: "settings.updated" } + ] + } +] as const; + +export const defaultWebhookEvents = webhookEventGroups[0].events.map((event) => event.value); +export const notificationRuleEventOptions = [ + { label: "新邮件到达", value: "message.received" }, + { label: "Webhook 提取结果", value: "message.extracted" }, + { label: "Telegram 提取结果", value: "message.extraction.detected" }, + { label: "邮件处理失败", value: "message.failed" }, + { label: "Telegram 测试", value: "telegram.test" }, + { label: "API 密钥创建", value: "api_key.created" }, + { label: "API 密钥吊销", value: "api_key.revoked" }, + { label: "配置变更", value: "settings.updated" } +]; +export const notificationTargetLabels: Record = { + webhook: "Webhook", + telegram: "Telegram", + slack: "Slack", + discord: "Discord", + feishu: "飞书", + wecom: "企业微信" +}; + +export const sampleHeaders = [ + "content-type: application/json", + "user-agent: WeMail-Webhook/1.0", + "x-wemail-event: message.received", + "x-wemail-delivery-id: whd_01H...", + "x-wemail-signature: sha256=..." +].join("\n"); + +export const samplePayload = JSON.stringify( + { + createdAt: "2026-04-17T11:30:00.000Z", + data: { + message: "WeMail webhook event", + messageId: "msg_01H...", + subject: "Your verification code" + }, + deliveryId: "whd_01H...", + endpoint: { + id: "whe_01H...", + name: "Production Sync" + }, + eventType: "message.received" + }, + null, + 2 +); + +export const signatureVerifyExample = JSON.stringify( + { + input: "raw request body + Signing Secret", + compareWith: "x-wemail-signature", + note: "Secret stays on your server; the header only contains the sha256 signature." + }, + null, + 2 +); + +export const signatureHelpItems = [ + "Signing Secret 只保存在 WeMail 和你的目标服务端,不会明文放进 Header。", + "WeMail 会用 Secret 对原始请求体计算 HMAC-SHA256,并把结果放到 x-wemail-signature。", + "目标服务收到请求后,用同一个 Secret 和原始 body 重新计算签名,再与 Header 比对。" +]; + +export type WebhookEndpoint = { + id: string; + name: string; + url: string; + events: string[]; + signingSecret?: string; + enabled: boolean; + createdAt?: string; + updatedAt?: string; +}; + +export type WebhookDelivery = { + id: string; + endpointId?: string; + eventType: string; + status: string; + statusCode: number | null; + durationMs: number | null; + errorText: string | null; + payload?: unknown; + responseText?: string | null; + createdAt: string; +}; + +export type NotificationRuleDraft = { + enabled: boolean; + eventTypes: string[]; + keyword: string; + mailboxIds: string; + name: string; + quietHoursEnd: string; + quietHoursStart: string; + target: NotificationRuleTarget; + targetId: string; +}; + +export type EndpointDraft = { + enabled: boolean; + events: string[]; + name: string; + url: string; +}; + +export type OverviewTone = "accent" | "info" | "success" | "warning"; + +export type WebhookEndpointListPayload = { + endpoints?: WebhookEndpoint[]; + page?: number; + pageSize?: number; + total?: number; +}; + +export type WebhookDeliveryListPayload = { + deliveries?: WebhookDelivery[]; + page?: number; + pageSize?: number; + total?: number; +}; + +export type NotificationRuleListPayload = { + rules?: NotificationRuleSummary[]; +}; + +export const emptyDraft: EndpointDraft = { + enabled: true, + events: defaultWebhookEvents, + name: "", + url: "" +}; + +export const emptyNotificationRuleDraft: NotificationRuleDraft = { + enabled: true, + eventTypes: ["message.received"], + keyword: "", + mailboxIds: "", + name: "", + quietHoursEnd: "", + quietHoursStart: "", + target: "webhook", + targetId: "" +}; + +export function readErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "Webhook 配置同步失败,请稍后重试。"; +} + +export function formatDate(value?: string) { + if (!value) return "尚未记录"; + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) return value; + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit" + }).format(new Date(timestamp)); +} + +export function normalizeStatus(status: string) { + const value = status.toLowerCase(); + if (value === "success" || value === "delivered" || value === "ok") return "成功"; + if (value === "failed" || value === "error") return "失败"; + if (value === "pending" || value === "retrying") return "重试中"; + return status; +} + +export function isSuccessfulDelivery(delivery: WebhookDelivery) { + const value = delivery.status.toLowerCase(); + return value === "success" || value === "delivered" || value === "ok"; +} + +export function getEventLabel(value: string) { + for (const group of webhookEventGroups) { + const match = group.events.find((event) => event.value === value); + if (match) return match.label; + } + return value; +} + +export function formatJson(value: unknown) { + if (typeof value === "undefined") return "{}"; + if (typeof value === "string") return value; + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value); + } +} + +export async function copyText(text: string) { + if (typeof navigator === "undefined" || !navigator.clipboard) return; + await navigator.clipboard.writeText(text); +} + + +export type WebhookEndpointDialogProps = { + createDraft: EndpointDraft; + errorMessage: string | null; + isCreateDraftValid: boolean; + isEditingEndpoint: boolean; + isSaving: boolean; + onDraftChange: (update: (current: EndpointDraft) => EndpointDraft) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export type WebhookDeliveryDialogProps = { + copiedToken: string | null; + delivery: WebhookDelivery; + isDeliveryLoading: boolean; + onClose: () => void; + onCopy: (token: string, text?: string) => void; + onRetry: (delivery: WebhookDelivery) => void; +}; diff --git a/apps/web/src/shared/api/client.ts b/apps/web/src/shared/api/client.ts index 5871be5..2cda10b 100644 --- a/apps/web/src/shared/api/client.ts +++ b/apps/web/src/shared/api/client.ts @@ -38,6 +38,26 @@ function buildRequestKey(method: string, url: string, cacheKey?: string) { return cacheKey ?? `${method}:${url}`; } +function toAbortError() { + return Object.assign(new Error("Aborted"), { name: "AbortError" }); +} + +// Races a shared promise against the caller's own signal: the caller rejects +// promptly on its own abort while the shared request keeps running for the +// participants that still want it. +function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(toAbortError()); + + let onAbort: (() => void) | undefined; + const abortPromise = new Promise((_, reject) => { + onAbort = () => reject(toAbortError()); + signal.addEventListener("abort", onAbort, { once: true }); + }); + return Promise.race([promise, abortPromise]).finally(() => { + if (onAbort) signal.removeEventListener("abort", onAbort); + }); +} + async function fetchJson(url: string, init?: RequestInit) { const response = await fetch(url, { credentials: "include", @@ -45,9 +65,14 @@ async function fetchJson(url: string, init?: RequestInit) { headers: { "content-type": "application/json", ...(init?.headers ?? {}) - } + }, + // Propagate caller aborts so stale polling/refresh requests can be + // cancelled on unmount or query change instead of running to completion. + signal: init?.signal }); + if (response.ok && (response.status === 204 || response.status === 205)) return undefined as T; + if (!response.ok) { const payload = (await response.json().catch(() => ({ error: `Request failed: ${response.status}` }))) as { error?: string; @@ -55,8 +80,6 @@ async function fetchJson(url: string, init?: RequestInit) { throw new Error(payload.error ?? `Request failed: ${response.status}`); } - if (response.status === 204 || response.status === 205) return undefined as T; - const text = await response.text(); if (!text) return undefined as T; @@ -125,7 +148,19 @@ export async function apiFetch(path: string, init?: ApiFetchOptions) { if (canReuseGet && dedupe) { const inFlightRequest = inFlightGetRequests.get(requestKey); - if (inFlightRequest) return inFlightRequest as Promise; + if (inFlightRequest) { + if (!requestInit.signal) return inFlightRequest as Promise; + // Joining a request other callers may abort for reasons that are not + // ours: our own abort rejects immediately via the race, and a foreign + // AbortError falls through so we issue our own request instead of + // surfacing an error we did not cause. + try { + return await raceWithSignal(inFlightRequest as Promise, requestInit.signal); + } catch (error) { + const isAbortError = error instanceof Error && error.name === "AbortError"; + if (!isAbortError || requestInit.signal.aborted) throw error; + } + } } const startedCacheVersion = getCacheVersion(requestKey); @@ -145,6 +180,8 @@ export async function apiFetch(path: string, init?: ApiFetchOptions) { try { return await request; } finally { - inFlightGetRequests.delete(requestKey); + // Identity guard: a retried request may have replaced this entry while the + // aborted original was unwinding; only remove our own. + if (inFlightGetRequests.get(requestKey) === request) inFlightGetRequests.delete(requestKey); } } diff --git a/apps/web/src/shared/form/DateInput.tsx b/apps/web/src/shared/form/DateInput.tsx new file mode 100644 index 0000000..bb1b71d --- /dev/null +++ b/apps/web/src/shared/form/DateInput.tsx @@ -0,0 +1,827 @@ +import { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, type ComponentPropsWithoutRef, type UIEvent as ReactUIEvent } from "react"; +import { createPortal } from "react-dom"; +import { CalendarDays, ChevronLeft, ChevronRight } from "lucide-react"; +import { type ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react"; + +export type DateInputProps = Omit, "type"> & { + calendarLabel?: string; + clearLabel?: string; + doneLabel?: string; + onValueChange?: (value: string) => void; + showTime?: boolean; + timeLabel?: string; + todayLabel?: string; +}; + +export type DateTimeInputProps = Omit; + +type CalendarViewMode = "day" | "month" | "year"; +type TimePart = "hours" | "minutes"; + +import { useFloatingPosition, usePortalRoot } from "../overlay/layer-utils"; +import { cx, mergeRefs, setNativeInputValue } from "./internal"; + +const dateWeekdayLabels = ["日", "一", "二", "三", "四", "五", "六"]; +const dateMonthFormatter = new Intl.DateTimeFormat("zh-CN", { + month: "long", + year: "numeric" +}); +const dateAccessibleFormatter = new Intl.DateTimeFormat("zh-CN", { + day: "numeric", + month: "long", + year: "numeric" +}); +const dateMonthNames = Array.from({ length: 12 }, (_, monthIndex) => `${monthIndex + 1}月`); +const timeHourOptions = Array.from({ length: 24 }, (_, hour) => padDatePart(hour)); +const timeMinuteOptions = Array.from({ length: 60 }, (_, minute) => padDatePart(minute)); + +function padDatePart(value: number) { + return String(value).padStart(2, "0"); +} + +function formatDateValue(date: Date) { + return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; +} + +function parseDateValue(value: unknown) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; + + const [yearPart, monthPart, dayPart] = value.split("-"); + const year = Number(yearPart); + const month = Number(monthPart); + const day = Number(dayPart); + const date = new Date(year, month - 1, day); + + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null; + + return date; +} + +function parseDateTimeValue(value: unknown) { + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/.test(value)) return null; + + const [datePart, timePart] = value.split(/[T ]/); + const date = parseDateValue(datePart); + if (!date) return null; + + const [hourPart, minutePart] = timePart.split(":"); + const hours = Number(hourPart); + const minutes = Number(minutePart); + if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; + + return new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes); +} + +function parseDateLikeValue(value: unknown) { + if (typeof value !== "string") return null; + + return parseDateTimeValue(value) ?? parseDateValue(value) ?? parseDateValue(value.split("T")[0] ?? ""); +} + +function formatDateTimeValue(date: Date) { + return `${formatDateValue(date)}T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`; +} + +function formatDateTimeDisplayValue(value: string) { + const parsedDate = parseDateTimeValue(value); + if (!parsedDate) return value; + return formatDateTimeValue(parsedDate).replace("T", " "); +} + +function normalizeDateTimeInputValue(value: string) { + const parsedDate = parseDateTimeValue(value); + if (!parsedDate) return value; + return formatDateTimeValue(parsedDate); +} + +function getTimePartsFromDateTime(value: string) { + const parsedDate = parseDateTimeValue(value); + if (parsedDate) { + return { + hours: padDatePart(parsedDate.getHours()), + minutes: padDatePart(parsedDate.getMinutes()) + }; + } + + const timeMatch = value.match(/[T ](?\d{0,2}):?(?\d{0,2})?/); + return { + hours: timeMatch?.groups?.hours?.padStart(2, "0").slice(0, 2) ?? "09", + minutes: timeMatch?.groups?.minutes?.padStart(2, "0").slice(0, 2) ?? "00" + }; +} + +function normalizeTimePart(part: string, max: number) { + const numericValue = Number(part || "0"); + if (!Number.isFinite(numericValue)) return "00"; + return padDatePart(Math.min(Math.max(numericValue, 0), max)); +} + +function getMonthStart(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function addMonths(date: Date, amount: number) { + return new Date(date.getFullYear(), date.getMonth() + amount, 1); +} + +function getCalendarDays(monthDate: Date) { + const monthStart = getMonthStart(monthDate); + const gridStart = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1 - monthStart.getDay()); + + return Array.from({ length: 42 }, (_, index) => ( + new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index) + )); +} + +function isSameCalendarDate(left: Date | null, right: Date | null) { + if (!left || !right) return false; + + return ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); +} + + +export const DateInput = forwardRef(function DateInput( + { + "aria-label": ariaLabel, + autoComplete = "off", + calendarLabel = "打开日期选择器", + className, + clearLabel = "清空日期", + defaultValue, + disabled, + doneLabel = "完成", + id, + inputMode, + onChange, + onClick, + onFocus, + onKeyDown, + onValueChange, + pattern, + placeholder, + readOnly, + showTime = false, + spellCheck = false, + timeLabel = "时间", + todayLabel = "今天", + value, + ...props + }, + ref +) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const calendarId = `${inputId}-calendar`; + const rootRef = useRef(null); + const inputRef = useRef(null); + const panelRef = useRef(null); + const hourWheelRef = useRef(null); + const minuteWheelRef = useRef(null); + const isProgrammaticChangeRef = useRef(false); + const suppressNextFocusOpenRef = useRef(false); + const timeWheelScrollTimeoutsRef = useRef>({ + hours: null, + minutes: null + }); + const portalRoot = usePortalRoot(); + const initialValue = Array.isArray(defaultValue) ? String(defaultValue[0] ?? "") : defaultValue?.toString() ?? ""; + const isControlled = value !== undefined; + const [uncontrolledValue, setUncontrolledValue] = useState(initialValue); + const currentValue = isControlled + ? Array.isArray(value) + ? String(value[0] ?? "") + : value?.toString() ?? "" + : uncontrolledValue; + const displayValue = showTime ? formatDateTimeDisplayValue(currentValue) : currentValue; + const displayDefaultValue = showTime + ? Array.isArray(defaultValue) + ? defaultValue.map((item) => formatDateTimeDisplayValue(String(item))) + : defaultValue === undefined || defaultValue === null + ? defaultValue + : formatDateTimeDisplayValue(String(defaultValue)) + : defaultValue; + const selectedDate = useMemo(() => parseDateLikeValue(currentValue), [currentValue]); + const timeParts = getTimePartsFromDateTime(currentValue); + const [hourDraft, setHourDraft] = useState(timeParts.hours); + const [minuteDraft, setMinuteDraft] = useState(timeParts.minutes); + const [visibleMonth, setVisibleMonth] = useState(() => getMonthStart(selectedDate ?? new Date())); + const [isOpen, setIsOpen] = useState(false); + const [calendarView, setCalendarView] = useState("day"); + const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]); + const yearRangeStart = Math.floor(visibleMonth.getFullYear() / 12) * 12; + const visibleYears = useMemo(() => Array.from({ length: 12 }, (_, index) => yearRangeStart + index), [yearRangeStart]); + const monthLabel = dateMonthFormatter.format(visibleMonth); + const resolvedInputMode = inputMode ?? (showTime ? "text" : "numeric"); + const resolvedPattern = pattern ?? (showTime ? "\\d{4}-\\d{2}-\\d{2}([T ]\\d{2}:\\d{2})?" : "\\d{4}-\\d{2}-\\d{2}"); + const resolvedPlaceholder = placeholder ?? (showTime ? "2026-07-01 09:00" : "2026-07-01…"); + const today = new Date(); + const { resolvedSide, style } = useFloatingPosition({ + anchorRef: rootRef, + contentRef: panelRef, + matchAnchorWidth: false, + offset: 8, + open: isOpen, + preferredSide: "bottom" + }); + + const scrollTimeWheel = useCallback((part: TimePart, nextValue: string, behavior: ScrollBehavior = "smooth") => { + const container = part === "hours" ? hourWheelRef.current : minuteWheelRef.current; + if (!container) return; + + const option = container.querySelector(`[data-time-value="${nextValue}"]`); + if (!option) return; + + const nextScrollTop = option.offsetTop - (container.clientHeight - option.offsetHeight) / 2; + if (typeof container.scrollTo === "function") { + container.scrollTo({ behavior, top: nextScrollTop }); + return; + } + + container.scrollTop = nextScrollTop; + }, []); + + useEffect(() => { + if (!showTime) return; + setHourDraft(timeParts.hours); + setMinuteDraft(timeParts.minutes); + }, [showTime, timeParts.hours, timeParts.minutes]); + + useEffect(() => { + const timeWheelScrollTimeouts = timeWheelScrollTimeoutsRef.current; + + return () => { + Object.values(timeWheelScrollTimeouts).forEach((timeoutId) => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + }); + }; + }, []); + + useEffect(() => { + if (!isOpen || !showTime) return; + + const animationFrame = window.requestAnimationFrame(() => { + scrollTimeWheel("hours", hourDraft, "auto"); + scrollTimeWheel("minutes", minuteDraft, "auto"); + }); + + return () => { + window.cancelAnimationFrame(animationFrame); + }; + }, [hourDraft, isOpen, minuteDraft, scrollTimeWheel, showTime]); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) return; + setIsOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + setVisibleMonth(getMonthStart(selectedDate ?? new Date())); + setCalendarView("day"); + }, [isOpen, selectedDate]); + + function openCalendar() { + if (disabled || readOnly) return; + setVisibleMonth(getMonthStart(selectedDate ?? new Date())); + setCalendarView("day"); + setIsOpen(true); + } + + function closeCalendar({ restoreFocus = false } = {}) { + setIsOpen(false); + if (restoreFocus) { + suppressNextFocusOpenRef.current = true; + inputRef.current?.focus(); + window.setTimeout(() => { + suppressNextFocusOpenRef.current = false; + }, 0); + } + } + + function commitValue(nextValue: string, { close = !showTime } = {}) { + const storedValue = showTime ? normalizeDateTimeInputValue(nextValue) : nextValue; + const nextDate = parseDateLikeValue(storedValue); + + if (!isControlled) { + setUncontrolledValue(storedValue); + } + + if (nextDate) { + setVisibleMonth(getMonthStart(nextDate)); + } + + const input = inputRef.current; + if (input) { + isProgrammaticChangeRef.current = true; + setNativeInputValue(input, showTime ? formatDateTimeDisplayValue(storedValue) : storedValue); + isProgrammaticChangeRef.current = false; + } + + onValueChange?.(storedValue); + if (close) { + closeCalendar({ restoreFocus: true }); + } + } + + function buildDateTimeValue(dateValue: string, nextHours = hourDraft, nextMinutes = minuteDraft) { + return `${dateValue}T${normalizeTimePart(nextHours, 23)}:${normalizeTimePart(nextMinutes, 59)}`; + } + + function commitDateValue(nextDateValue: string, { close = !showTime } = {}) { + commitValue(showTime && nextDateValue ? buildDateTimeValue(nextDateValue) : nextDateValue, { close }); + } + + function handleInputChange(event: ChangeEvent) { + const nextValue = event.currentTarget.value; + const storedValue = showTime ? normalizeDateTimeInputValue(nextValue) : nextValue; + + if (!isControlled) { + setUncontrolledValue(storedValue); + } + + const nextDate = parseDateLikeValue(storedValue); + if (nextDate) { + setVisibleMonth(getMonthStart(nextDate)); + } + + if (showTime) { + const nextTimeParts = getTimePartsFromDateTime(storedValue); + setHourDraft(nextTimeParts.hours); + setMinuteDraft(nextTimeParts.minutes); + } + + onChange?.(event); + + if (!isProgrammaticChangeRef.current) { + onValueChange?.(storedValue); + } + } + + function handleInputKeyDown(event: ReactKeyboardEvent) { + onKeyDown?.(event); + if (event.defaultPrevented) return; + + if (event.key === "ArrowDown") { + event.preventDefault(); + openCalendar(); + } + + if (event.key === "Escape") { + closeCalendar(); + } + } + + function handleDayKeyDown(index: number) { + return (event: ReactKeyboardEvent) => { + const dayButtons = Array.from(panelRef.current?.querySelectorAll(".ui-date-picker-day") ?? []); + const focusDay = (nextIndex: number) => dayButtons[nextIndex]?.focus(); + + if (event.key === "ArrowRight") { + event.preventDefault(); + focusDay(Math.min(index + 1, dayButtons.length - 1)); + } + + if (event.key === "ArrowLeft") { + event.preventDefault(); + focusDay(Math.max(index - 1, 0)); + } + + if (event.key === "ArrowDown") { + event.preventDefault(); + focusDay(Math.min(index + 7, dayButtons.length - 1)); + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + focusDay(Math.max(index - 7, 0)); + } + + if (event.key === "Home") { + event.preventDefault(); + focusDay(0); + } + + if (event.key === "End") { + event.preventDefault(); + focusDay(dayButtons.length - 1); + } + + if (event.key === "Escape") { + event.preventDefault(); + closeCalendar({ restoreFocus: true }); + } + }; + } + + function commitCurrentTime(nextHours = hourDraft, nextMinutes = minuteDraft, { close = false } = {}) { + if (!selectedDate) return; + commitValue(buildDateTimeValue(formatDateValue(selectedDate), nextHours, nextMinutes), { close }); + } + + function getTimeWheelOptions(part: TimePart) { + return part === "hours" ? timeHourOptions : timeMinuteOptions; + } + + function getClosestTimeWheelValue(container: HTMLDivElement) { + const options = Array.from(container.querySelectorAll("[data-time-value]")); + if (options.length === 0) return null; + + const center = container.scrollTop + container.clientHeight / 2; + const closestOption = options.reduce((closest, option) => { + const closestDistance = Math.abs(closest.offsetTop + closest.offsetHeight / 2 - center); + const optionDistance = Math.abs(option.offsetTop + option.offsetHeight / 2 - center); + return optionDistance < closestDistance ? option : closest; + }, options[0]); + + return closestOption.dataset.timeValue ?? null; + } + + function commitTimePart(part: TimePart, nextValue: string, { scroll = true } = {}) { + const normalizedValue = normalizeTimePart(nextValue, part === "hours" ? 23 : 59); + const nextHours = part === "hours" ? normalizedValue : hourDraft; + const nextMinutes = part === "minutes" ? normalizedValue : minuteDraft; + + if (part === "hours") { + setHourDraft(normalizedValue); + } else { + setMinuteDraft(normalizedValue); + } + + if (selectedDate) { + commitCurrentTime(nextHours, nextMinutes); + } + + if (scroll) { + scrollTimeWheel(part, normalizedValue); + } + } + + function handleTimeWheelScroll(part: TimePart) { + return (event: ReactUIEvent) => { + const timeoutId = timeWheelScrollTimeoutsRef.current[part]; + if (timeoutId !== null) { + window.clearTimeout(timeoutId); + } + + const container = event.currentTarget; + timeWheelScrollTimeoutsRef.current[part] = window.setTimeout(() => { + const nextValue = getClosestTimeWheelValue(container); + if (nextValue) { + commitTimePart(part, nextValue, { scroll: true }); + } + }, 90); + }; + } + + function handleTimeWheelKeyDown(part: TimePart) { + return (event: ReactKeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeCalendar({ restoreFocus: true }); + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + closeCalendar({ restoreFocus: true }); + return; + } + + const options = getTimeWheelOptions(part); + const currentValue = part === "hours" ? hourDraft : minuteDraft; + const currentIndex = Math.max(0, options.indexOf(currentValue)); + let nextIndex = currentIndex; + + if (event.key === "ArrowDown") nextIndex = Math.min(currentIndex + 1, options.length - 1); + if (event.key === "ArrowUp") nextIndex = Math.max(currentIndex - 1, 0); + if (event.key === "PageDown") nextIndex = Math.min(currentIndex + 5, options.length - 1); + if (event.key === "PageUp") nextIndex = Math.max(currentIndex - 5, 0); + if (event.key === "Home") nextIndex = 0; + if (event.key === "End") nextIndex = options.length - 1; + + if (nextIndex === currentIndex) return; + + event.preventDefault(); + commitTimePart(part, options[nextIndex]); + }; + } + + return ( +
+ { + onClick?.(event); + openCalendar(); + }} + onFocus={(event) => { + onFocus?.(event); + if (suppressNextFocusOpenRef.current) return; + openCalendar(); + }} + onKeyDown={handleInputKeyDown} + pattern={resolvedPattern} + placeholder={resolvedPlaceholder} + readOnly={readOnly} + ref={mergeRefs(ref, inputRef)} + spellCheck={spellCheck} + type="text" + value={isControlled ? displayValue : undefined} + /> + + + {isOpen && portalRoot + ? createPortal( +
{ + if (event.key === "Escape") { + event.preventDefault(); + closeCalendar({ restoreFocus: true }); + } + }} + ref={panelRef} + role="dialog" + style={style} + > +
+
+
+ + + +
+ {calendarView === "day" ? ( + <> + +
+ {calendarDays.map((day, index) => { + const dateValue = formatDateValue(day); + const isOutsideMonth = day.getMonth() !== visibleMonth.getMonth(); + const isSelected = isSameCalendarDate(day, selectedDate); + const isToday = isSameCalendarDate(day, today); + + return ( + + ); + })} +
+ + ) : calendarView === "month" ? ( +
+ {dateMonthNames.map((monthName, monthIndex) => ( + + ))} +
+ ) : ( +
+ {visibleYears.map((year) => ( + + ))} +
+ )} +
+ {showTime ? ( +
+ {timeLabel} +
+
+ {timeHourOptions.map((hour) => ( + + ))} +
+ +
+ {timeMinuteOptions.map((minute) => ( + + ))} +
+
+
+ ) : null} +
+
+ +
+ + {showTime ? ( + + ) : null} +
+
+
, + portalRoot + ) + : null} +
+ ); +}); + +export const DateTimeInput = forwardRef(function DateTimeInput(props, ref) { + return ; +}); + diff --git a/apps/web/src/shared/form/FormPrimitives.tsx b/apps/web/src/shared/form/FormPrimitives.tsx index c1c671a..fee55c7 100644 --- a/apps/web/src/shared/form/FormPrimitives.tsx +++ b/apps/web/src/shared/form/FormPrimitives.tsx @@ -1,34 +1,37 @@ import { cloneElement, - Children, forwardRef, isValidElement, - useCallback, - useEffect, useId, - useMemo, useRef, useState, type ChangeEvent, type ChangeEventHandler, type ComponentPropsWithoutRef, type InputHTMLAttributes, - type KeyboardEvent as ReactKeyboardEvent, type ReactNode, - type ReactElement, - type Ref, - type SelectHTMLAttributes, - type TextareaHTMLAttributes, - type UIEvent as ReactUIEvent + type TextareaHTMLAttributes } from "react"; -import { createPortal } from "react-dom"; -import { CalendarDays, Check, ChevronDown, ChevronLeft, ChevronRight, Search, X } from "lucide-react"; +import { Search, X } from "lucide-react"; -import { useFloatingPosition, usePortalRoot } from "../overlay/layer-utils"; +import { + cx, + mergeIds, + renderFieldLabel, + renderFieldMeta, + renderFormCheck, + mergeRefs, + setNativeInputValue, + type FormTone +} from "./internal"; -type FormTone = "default" | "error" | "success"; type FormCheckVariant = "inline" | "card"; +// Type-only re-export keeps MultiSelectOption single-sourced in MultiSelect.tsx +// without creating a runtime cycle (MultiSelect imports Checkbox from here). +export type { MultiSelectOption } from "./MultiSelect"; + + type FormFieldProps = { children: ReactNode; className?: string; @@ -80,359 +83,7 @@ type SearchInputProps = Omit, "type"> & { onClear?: () => void; }; -type DateInputProps = Omit, "type"> & { - calendarLabel?: string; - clearLabel?: string; - doneLabel?: string; - onValueChange?: (value: string) => void; - showTime?: boolean; - timeLabel?: string; - todayLabel?: string; -}; - -type DateTimeInputProps = Omit; - -type CalendarViewMode = "day" | "month" | "year"; -type TimePart = "hours" | "minutes"; - -type SelectOptionRecord = { - disabled?: boolean; - label: ReactNode; - textValue: string; - value: string; -}; - -export type MultiSelectOption = { - description?: ReactNode; - disabled?: boolean; - label: ReactNode; - value: string; -}; - -type MultiSelectProps = Omit, "defaultValue" | "onChange"> & { - clearLabel?: string; - defaultValue?: string[]; - disabled?: boolean; - emptyText?: ReactNode; - name?: string; - onValueChange?: (nextValue: string[]) => void; - options: MultiSelectOption[]; - placeholder?: ReactNode; - value?: string[]; -}; - -function cx(...parts: Array) { - return parts.filter(Boolean).join(" "); -} - -function mergeIds(...values: Array) { - return values.filter(Boolean).join(" ") || undefined; -} - -function assignRef(ref: Ref | undefined, value: T) { - if (!ref) return; - if (typeof ref === "function") { - ref(value); - return; - } - ref.current = value; -} - -function mergeRefs(...refs: Array | undefined>) { - return (value: T) => { - refs.forEach((ref) => assignRef(ref, value)); - }; -} - -function setNativeInputValue(input: HTMLInputElement, nextValue: string) { - const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); - descriptor?.set?.call(input, nextValue); - input.dispatchEvent(new Event("input", { bubbles: true })); -} - -function setNativeSelectValue(select: HTMLSelectElement, nextValue: string) { - const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value"); - descriptor?.set?.call(select, nextValue); - select.dispatchEvent(new Event("change", { bubbles: true })); -} - -function getTextValue(node: ReactNode): string { - if (typeof node === "string" || typeof node === "number") { - return String(node); - } - - if (Array.isArray(node)) { - return node.map(getTextValue).join(""); - } - - if (isValidElement<{ children?: ReactNode }>(node)) { - return getTextValue(node.props.children); - } - - return ""; -} - -function getSelectOptions(children: ReactNode): SelectOptionRecord[] { - return Children.toArray(children) - .filter((child): child is ReactElement> => { - return isValidElement>(child) && child.type === "option"; - }) - .map((child) => { - const label = child.props.children; - const textValue = getTextValue(label); - const value = child.props.value === undefined ? textValue : String(child.props.value); - - return { - disabled: child.props.disabled, - label, - textValue, - value - }; - }); -} - -function normalizeSelectValue(value: SelectHTMLAttributes["value"] | SelectHTMLAttributes["defaultValue"]) { - if (Array.isArray(value)) return String(value[0] ?? ""); - if (value === undefined || value === null) return undefined; - return String(value); -} - -const dateWeekdayLabels = ["日", "一", "二", "三", "四", "五", "六"]; -const dateMonthFormatter = new Intl.DateTimeFormat("zh-CN", { - month: "long", - year: "numeric" -}); -const dateAccessibleFormatter = new Intl.DateTimeFormat("zh-CN", { - day: "numeric", - month: "long", - year: "numeric" -}); -const dateMonthNames = Array.from({ length: 12 }, (_, monthIndex) => `${monthIndex + 1}月`); -const timeHourOptions = Array.from({ length: 24 }, (_, hour) => padDatePart(hour)); -const timeMinuteOptions = Array.from({ length: 60 }, (_, minute) => padDatePart(minute)); - -function padDatePart(value: number) { - return String(value).padStart(2, "0"); -} - -function formatDateValue(date: Date) { - return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}-${padDatePart(date.getDate())}`; -} - -function parseDateValue(value: unknown) { - if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null; - - const [yearPart, monthPart, dayPart] = value.split("-"); - const year = Number(yearPart); - const month = Number(monthPart); - const day = Number(dayPart); - const date = new Date(year, month - 1, day); - if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) return null; - - return date; -} - -function parseDateTimeValue(value: unknown) { - if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}$/.test(value)) return null; - - const [datePart, timePart] = value.split(/[T ]/); - const date = parseDateValue(datePart); - if (!date) return null; - - const [hourPart, minutePart] = timePart.split(":"); - const hours = Number(hourPart); - const minutes = Number(minutePart); - if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null; - - return new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, minutes); -} - -function parseDateLikeValue(value: unknown) { - if (typeof value !== "string") return null; - - return parseDateTimeValue(value) ?? parseDateValue(value) ?? parseDateValue(value.split("T")[0] ?? ""); -} - -function formatDateTimeValue(date: Date) { - return `${formatDateValue(date)}T${padDatePart(date.getHours())}:${padDatePart(date.getMinutes())}`; -} - -function formatDateTimeDisplayValue(value: string) { - const parsedDate = parseDateTimeValue(value); - if (!parsedDate) return value; - return formatDateTimeValue(parsedDate).replace("T", " "); -} - -function normalizeDateTimeInputValue(value: string) { - const parsedDate = parseDateTimeValue(value); - if (!parsedDate) return value; - return formatDateTimeValue(parsedDate); -} - -function getTimePartsFromDateTime(value: string) { - const parsedDate = parseDateTimeValue(value); - if (parsedDate) { - return { - hours: padDatePart(parsedDate.getHours()), - minutes: padDatePart(parsedDate.getMinutes()) - }; - } - - const timeMatch = value.match(/[T ](?\d{0,2}):?(?\d{0,2})?/); - return { - hours: timeMatch?.groups?.hours?.padStart(2, "0").slice(0, 2) ?? "09", - minutes: timeMatch?.groups?.minutes?.padStart(2, "0").slice(0, 2) ?? "00" - }; -} - -function normalizeTimePart(part: string, max: number) { - const numericValue = Number(part || "0"); - if (!Number.isFinite(numericValue)) return "00"; - return padDatePart(Math.min(Math.max(numericValue, 0), max)); -} - -function getMonthStart(date: Date) { - return new Date(date.getFullYear(), date.getMonth(), 1); -} - -function addMonths(date: Date, amount: number) { - return new Date(date.getFullYear(), date.getMonth() + amount, 1); -} - -function getCalendarDays(monthDate: Date) { - const monthStart = getMonthStart(monthDate); - const gridStart = new Date(monthStart.getFullYear(), monthStart.getMonth(), 1 - monthStart.getDay()); - - return Array.from({ length: 42 }, (_, index) => ( - new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index) - )); -} - -function isSameCalendarDate(left: Date | null, right: Date | null) { - if (!left || !right) return false; - - return ( - left.getFullYear() === right.getFullYear() && - left.getMonth() === right.getMonth() && - left.getDate() === right.getDate() - ); -} - -function renderFieldMeta( - description?: ReactNode, - descriptionId?: string, - message?: ReactNode, - messageId?: string, - tone: FormTone = "default" -) { - return ( - <> - {description ? ( - - {description} - - ) : null} - {message ? ( - - {message} - - ) : null} - - ); -} - -function renderFieldLabel(label: ReactNode, required?: boolean) { - return ( - - {label} - {required ? ( - - ) : null} - - ); -} - -function renderFormCheck( - type: "checkbox" | "radio", - inputId: string, - { - className, - description, - inputClassName, - label, - variant = "inline", - ...props - }: FormCheckProps, - ref: Ref -) { - const descriptionId = description ? `${inputId}-description` : undefined; - const isChecked = props.checked ?? props.defaultChecked; - - return ( -
- - - - {description ? ( - - {description} - - ) : null} - -
- ); -} - -function getMultiSelectSummary(selectedValues: string[], options: MultiSelectOption[], placeholder: ReactNode) { - if (selectedValues.length === 0) return placeholder; - - const labels = selectedValues - .map((selectedValue) => options.find((option) => option.value === selectedValue)?.label) - .filter((label): label is ReactNode => label !== undefined); - - return labels.map((label, index) => ( - - {index > 0 ? ", " : null} - {label} - - )); -} - -function moveFocus(optionRefs: Array, startIndex: number, step: 1 | -1) { - const total = optionRefs.length; - let cursor = startIndex; - - for (let attempts = 0; attempts < total; attempts += 1) { - cursor = (cursor + step + total) % total; - const next = optionRefs[cursor]; - - if (next && !next.hasAttribute("disabled") && next.getAttribute("aria-disabled") !== "true") { - next.focus(); - return; - } - } -} export function FormField({ children, className, description, htmlFor, label, message, required, tone = "default" }: FormFieldProps) { const generatedId = useId(); @@ -541,916 +192,6 @@ export const SearchInput = forwardRef(functi } ); -export const DateInput = forwardRef(function DateInput( - { - "aria-label": ariaLabel, - autoComplete = "off", - calendarLabel = "打开日期选择器", - className, - clearLabel = "清空日期", - defaultValue, - disabled, - doneLabel = "完成", - id, - inputMode, - onChange, - onClick, - onFocus, - onKeyDown, - onValueChange, - pattern, - placeholder, - readOnly, - showTime = false, - spellCheck = false, - timeLabel = "时间", - todayLabel = "今天", - value, - ...props - }, - ref -) { - const generatedId = useId(); - const inputId = id ?? generatedId; - const calendarId = `${inputId}-calendar`; - const rootRef = useRef(null); - const inputRef = useRef(null); - const panelRef = useRef(null); - const hourWheelRef = useRef(null); - const minuteWheelRef = useRef(null); - const isProgrammaticChangeRef = useRef(false); - const suppressNextFocusOpenRef = useRef(false); - const timeWheelScrollTimeoutsRef = useRef>({ - hours: null, - minutes: null - }); - const portalRoot = usePortalRoot(); - const initialValue = Array.isArray(defaultValue) ? String(defaultValue[0] ?? "") : defaultValue?.toString() ?? ""; - const isControlled = value !== undefined; - const [uncontrolledValue, setUncontrolledValue] = useState(initialValue); - const currentValue = isControlled - ? Array.isArray(value) - ? String(value[0] ?? "") - : value?.toString() ?? "" - : uncontrolledValue; - const displayValue = showTime ? formatDateTimeDisplayValue(currentValue) : currentValue; - const displayDefaultValue = showTime - ? Array.isArray(defaultValue) - ? defaultValue.map((item) => formatDateTimeDisplayValue(String(item))) - : defaultValue === undefined || defaultValue === null - ? defaultValue - : formatDateTimeDisplayValue(String(defaultValue)) - : defaultValue; - const selectedDate = useMemo(() => parseDateLikeValue(currentValue), [currentValue]); - const timeParts = getTimePartsFromDateTime(currentValue); - const [hourDraft, setHourDraft] = useState(timeParts.hours); - const [minuteDraft, setMinuteDraft] = useState(timeParts.minutes); - const [visibleMonth, setVisibleMonth] = useState(() => getMonthStart(selectedDate ?? new Date())); - const [isOpen, setIsOpen] = useState(false); - const [calendarView, setCalendarView] = useState("day"); - const calendarDays = useMemo(() => getCalendarDays(visibleMonth), [visibleMonth]); - const yearRangeStart = Math.floor(visibleMonth.getFullYear() / 12) * 12; - const visibleYears = useMemo(() => Array.from({ length: 12 }, (_, index) => yearRangeStart + index), [yearRangeStart]); - const monthLabel = dateMonthFormatter.format(visibleMonth); - const resolvedInputMode = inputMode ?? (showTime ? "text" : "numeric"); - const resolvedPattern = pattern ?? (showTime ? "\\d{4}-\\d{2}-\\d{2}([T ]\\d{2}:\\d{2})?" : "\\d{4}-\\d{2}-\\d{2}"); - const resolvedPlaceholder = placeholder ?? (showTime ? "2026-07-01 09:00" : "2026-07-01…"); - const today = new Date(); - const { resolvedSide, style } = useFloatingPosition({ - anchorRef: rootRef, - contentRef: panelRef, - matchAnchorWidth: false, - offset: 8, - open: isOpen, - preferredSide: "bottom" - }); - - const scrollTimeWheel = useCallback((part: TimePart, nextValue: string, behavior: ScrollBehavior = "smooth") => { - const container = part === "hours" ? hourWheelRef.current : minuteWheelRef.current; - if (!container) return; - - const option = container.querySelector(`[data-time-value="${nextValue}"]`); - if (!option) return; - - const nextScrollTop = option.offsetTop - (container.clientHeight - option.offsetHeight) / 2; - if (typeof container.scrollTo === "function") { - container.scrollTo({ behavior, top: nextScrollTop }); - return; - } - - container.scrollTop = nextScrollTop; - }, []); - - useEffect(() => { - if (!showTime) return; - setHourDraft(timeParts.hours); - setMinuteDraft(timeParts.minutes); - }, [showTime, timeParts.hours, timeParts.minutes]); - - useEffect(() => { - const timeWheelScrollTimeouts = timeWheelScrollTimeoutsRef.current; - - return () => { - Object.values(timeWheelScrollTimeouts).forEach((timeoutId) => { - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - }); - }; - }, []); - - useEffect(() => { - if (!isOpen || !showTime) return; - - const animationFrame = window.requestAnimationFrame(() => { - scrollTimeWheel("hours", hourDraft, "auto"); - scrollTimeWheel("minutes", minuteDraft, "auto"); - }); - - return () => { - window.cancelAnimationFrame(animationFrame); - }; - }, [hourDraft, isOpen, minuteDraft, scrollTimeWheel, showTime]); - - useEffect(() => { - if (!isOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Node)) return; - if (rootRef.current?.contains(target) || panelRef.current?.contains(target)) return; - setIsOpen(false); - }; - - document.addEventListener("mousedown", handlePointerDown); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - }; - }, [isOpen]); - - useEffect(() => { - if (!isOpen) return; - setVisibleMonth(getMonthStart(selectedDate ?? new Date())); - setCalendarView("day"); - }, [isOpen, selectedDate]); - - function openCalendar() { - if (disabled || readOnly) return; - setVisibleMonth(getMonthStart(selectedDate ?? new Date())); - setCalendarView("day"); - setIsOpen(true); - } - - function closeCalendar({ restoreFocus = false } = {}) { - setIsOpen(false); - if (restoreFocus) { - suppressNextFocusOpenRef.current = true; - inputRef.current?.focus(); - window.setTimeout(() => { - suppressNextFocusOpenRef.current = false; - }, 0); - } - } - - function commitValue(nextValue: string, { close = !showTime } = {}) { - const storedValue = showTime ? normalizeDateTimeInputValue(nextValue) : nextValue; - const nextDate = parseDateLikeValue(storedValue); - - if (!isControlled) { - setUncontrolledValue(storedValue); - } - - if (nextDate) { - setVisibleMonth(getMonthStart(nextDate)); - } - - const input = inputRef.current; - if (input) { - isProgrammaticChangeRef.current = true; - setNativeInputValue(input, showTime ? formatDateTimeDisplayValue(storedValue) : storedValue); - isProgrammaticChangeRef.current = false; - } - - onValueChange?.(storedValue); - if (close) { - closeCalendar({ restoreFocus: true }); - } - } - - function buildDateTimeValue(dateValue: string, nextHours = hourDraft, nextMinutes = minuteDraft) { - return `${dateValue}T${normalizeTimePart(nextHours, 23)}:${normalizeTimePart(nextMinutes, 59)}`; - } - - function commitDateValue(nextDateValue: string, { close = !showTime } = {}) { - commitValue(showTime && nextDateValue ? buildDateTimeValue(nextDateValue) : nextDateValue, { close }); - } - - function handleInputChange(event: ChangeEvent) { - const nextValue = event.currentTarget.value; - const storedValue = showTime ? normalizeDateTimeInputValue(nextValue) : nextValue; - - if (!isControlled) { - setUncontrolledValue(storedValue); - } - - const nextDate = parseDateLikeValue(storedValue); - if (nextDate) { - setVisibleMonth(getMonthStart(nextDate)); - } - - if (showTime) { - const nextTimeParts = getTimePartsFromDateTime(storedValue); - setHourDraft(nextTimeParts.hours); - setMinuteDraft(nextTimeParts.minutes); - } - - onChange?.(event); - - if (!isProgrammaticChangeRef.current) { - onValueChange?.(storedValue); - } - } - - function handleInputKeyDown(event: ReactKeyboardEvent) { - onKeyDown?.(event); - if (event.defaultPrevented) return; - - if (event.key === "ArrowDown") { - event.preventDefault(); - openCalendar(); - } - - if (event.key === "Escape") { - closeCalendar(); - } - } - - function handleDayKeyDown(index: number) { - return (event: ReactKeyboardEvent) => { - const dayButtons = Array.from(panelRef.current?.querySelectorAll(".ui-date-picker-day") ?? []); - const focusDay = (nextIndex: number) => dayButtons[nextIndex]?.focus(); - - if (event.key === "ArrowRight") { - event.preventDefault(); - focusDay(Math.min(index + 1, dayButtons.length - 1)); - } - - if (event.key === "ArrowLeft") { - event.preventDefault(); - focusDay(Math.max(index - 1, 0)); - } - - if (event.key === "ArrowDown") { - event.preventDefault(); - focusDay(Math.min(index + 7, dayButtons.length - 1)); - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - focusDay(Math.max(index - 7, 0)); - } - - if (event.key === "Home") { - event.preventDefault(); - focusDay(0); - } - - if (event.key === "End") { - event.preventDefault(); - focusDay(dayButtons.length - 1); - } - - if (event.key === "Escape") { - event.preventDefault(); - closeCalendar({ restoreFocus: true }); - } - }; - } - - function commitCurrentTime(nextHours = hourDraft, nextMinutes = minuteDraft, { close = false } = {}) { - if (!selectedDate) return; - commitValue(buildDateTimeValue(formatDateValue(selectedDate), nextHours, nextMinutes), { close }); - } - - function getTimeWheelOptions(part: TimePart) { - return part === "hours" ? timeHourOptions : timeMinuteOptions; - } - - function getClosestTimeWheelValue(container: HTMLDivElement) { - const options = Array.from(container.querySelectorAll("[data-time-value]")); - if (options.length === 0) return null; - - const center = container.scrollTop + container.clientHeight / 2; - const closestOption = options.reduce((closest, option) => { - const closestDistance = Math.abs(closest.offsetTop + closest.offsetHeight / 2 - center); - const optionDistance = Math.abs(option.offsetTop + option.offsetHeight / 2 - center); - return optionDistance < closestDistance ? option : closest; - }, options[0]); - - return closestOption.dataset.timeValue ?? null; - } - - function commitTimePart(part: TimePart, nextValue: string, { scroll = true } = {}) { - const normalizedValue = normalizeTimePart(nextValue, part === "hours" ? 23 : 59); - const nextHours = part === "hours" ? normalizedValue : hourDraft; - const nextMinutes = part === "minutes" ? normalizedValue : minuteDraft; - - if (part === "hours") { - setHourDraft(normalizedValue); - } else { - setMinuteDraft(normalizedValue); - } - - if (selectedDate) { - commitCurrentTime(nextHours, nextMinutes); - } - - if (scroll) { - scrollTimeWheel(part, normalizedValue); - } - } - - function handleTimeWheelScroll(part: TimePart) { - return (event: ReactUIEvent) => { - const timeoutId = timeWheelScrollTimeoutsRef.current[part]; - if (timeoutId !== null) { - window.clearTimeout(timeoutId); - } - - const container = event.currentTarget; - timeWheelScrollTimeoutsRef.current[part] = window.setTimeout(() => { - const nextValue = getClosestTimeWheelValue(container); - if (nextValue) { - commitTimePart(part, nextValue, { scroll: true }); - } - }, 90); - }; - } - - function handleTimeWheelKeyDown(part: TimePart) { - return (event: ReactKeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - closeCalendar({ restoreFocus: true }); - return; - } - - if (event.key === "Enter") { - event.preventDefault(); - closeCalendar({ restoreFocus: true }); - return; - } - - const options = getTimeWheelOptions(part); - const currentValue = part === "hours" ? hourDraft : minuteDraft; - const currentIndex = Math.max(0, options.indexOf(currentValue)); - let nextIndex = currentIndex; - - if (event.key === "ArrowDown") nextIndex = Math.min(currentIndex + 1, options.length - 1); - if (event.key === "ArrowUp") nextIndex = Math.max(currentIndex - 1, 0); - if (event.key === "PageDown") nextIndex = Math.min(currentIndex + 5, options.length - 1); - if (event.key === "PageUp") nextIndex = Math.max(currentIndex - 5, 0); - if (event.key === "Home") nextIndex = 0; - if (event.key === "End") nextIndex = options.length - 1; - - if (nextIndex === currentIndex) return; - - event.preventDefault(); - commitTimePart(part, options[nextIndex]); - }; - } - - return ( -
- { - onClick?.(event); - openCalendar(); - }} - onFocus={(event) => { - onFocus?.(event); - if (suppressNextFocusOpenRef.current) return; - openCalendar(); - }} - onKeyDown={handleInputKeyDown} - pattern={resolvedPattern} - placeholder={resolvedPlaceholder} - readOnly={readOnly} - ref={mergeRefs(ref, inputRef)} - spellCheck={spellCheck} - type="text" - value={isControlled ? displayValue : undefined} - /> - - - {isOpen && portalRoot - ? createPortal( -
{ - if (event.key === "Escape") { - event.preventDefault(); - closeCalendar({ restoreFocus: true }); - } - }} - ref={panelRef} - role="dialog" - style={style} - > -
-
-
- - - -
- {calendarView === "day" ? ( - <> - -
- {calendarDays.map((day, index) => { - const dateValue = formatDateValue(day); - const isOutsideMonth = day.getMonth() !== visibleMonth.getMonth(); - const isSelected = isSameCalendarDate(day, selectedDate); - const isToday = isSameCalendarDate(day, today); - - return ( - - ); - })} -
- - ) : calendarView === "month" ? ( -
- {dateMonthNames.map((monthName, monthIndex) => ( - - ))} -
- ) : ( -
- {visibleYears.map((year) => ( - - ))} -
- )} -
- {showTime ? ( -
- {timeLabel} -
-
- {timeHourOptions.map((hour) => ( - - ))} -
- -
- {timeMinuteOptions.map((minute) => ( - - ))} -
-
-
- ) : null} -
-
- -
- - {showTime ? ( - - ) : null} -
-
-
, - portalRoot - ) - : null} -
- ); -}); - -export const DateTimeInput = forwardRef(function DateTimeInput(props, ref) { - return ; -}); - -export const SelectInput = forwardRef>(function SelectInput( - { - "aria-describedby": ariaDescribedBy, - "aria-label": ariaLabel, - children, - className, - defaultValue, - disabled, - id, - name, - onChange, - required, - value, - ...props - }, - ref -) { - const generatedId = useId(); - const triggerId = id ?? generatedId; - const listboxId = `${triggerId}-listbox`; - const nativeSelectRef = useRef(null); - const triggerRef = useRef(null); - const panelRef = useRef(null); - const optionRefs = useRef>([]); - const portalRoot = usePortalRoot(); - const options = useMemo(() => getSelectOptions(children), [children]); - const fallbackValue = options[0]?.value ?? ""; - const defaultSelectValue = normalizeSelectValue(defaultValue) ?? fallbackValue; - const isControlled = value !== undefined; - const [internalValue, setInternalValue] = useState(defaultSelectValue); - const [isOpen, setIsOpen] = useState(false); - const selectedValue = normalizeSelectValue(isControlled ? value : internalValue) ?? fallbackValue; - const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0]; - const { resolvedSide, style } = useFloatingPosition({ - anchorRef: triggerRef, - contentRef: panelRef, - matchAnchorWidth: true, - open: isOpen, - preferredSide: "bottom" - }); - - useEffect(() => { - if (!isOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - const target = event.target; - if (!(target instanceof Node)) return; - if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; - setIsOpen(false); - }; - - document.addEventListener("mousedown", handlePointerDown); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - }; - }, [isOpen]); - - useEffect(() => { - if (!isOpen) return; - - const selectedIndex = options.findIndex((option) => option.value === selectedValue && !option.disabled); - const firstEnabledIndex = options.findIndex((option) => !option.disabled); - const focusIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex; - - optionRefs.current[focusIndex]?.focus(); - }, [isOpen, options, selectedValue]); - - function closeListbox() { - setIsOpen(false); - triggerRef.current?.focus(); - } - - function commitValue(nextValue: string) { - if (!isControlled) { - setInternalValue(nextValue); - } - - const nativeSelect = nativeSelectRef.current; - if (nativeSelect) { - setNativeSelectValue(nativeSelect, nextValue); - } - - setIsOpen(false); - triggerRef.current?.focus(); - } - - function handleNativeChange(event: ChangeEvent) { - if (!isControlled) { - setInternalValue(event.currentTarget.value); - } - - onChange?.(event); - } - - function handleTriggerKeyDown(event: ReactKeyboardEvent) { - if (disabled) return; - - if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setIsOpen(true); - } - } - - function handleOptionKeyDown(index: number) { - return (event: ReactKeyboardEvent) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - moveFocus(optionRefs.current, index, 1); - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - moveFocus(optionRefs.current, index, -1); - } - - if (event.key === "Home") { - event.preventDefault(); - optionRefs.current.find((option) => option && !option.disabled)?.focus(); - } - - if (event.key === "End") { - event.preventDefault(); - [...optionRefs.current].reverse().find((option) => option && !option.disabled)?.focus(); - } - - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - const option = options[index]; - if (!option || option.disabled) return; - commitValue(option.value); - } - - if (event.key === "Escape") { - event.preventDefault(); - closeListbox(); - } - }; - } - - return ( -
- - - - - {isOpen && portalRoot - ? createPortal( -
- {options.map((option, index) => { - const isSelected = option.value === selectedValue; - - return ( - - ); - })} -
, - portalRoot - ) - : null} -
- ); -}); export const TextareaInput = forwardRef>(function TextareaInput( { className, ...props }, @@ -1473,184 +214,6 @@ export function CheckboxField(props: CheckboxFieldProps) { return ; } -export const MultiSelect = forwardRef(function MultiSelect( - { - "aria-label": ariaLabel, - className, - clearLabel = "清空选择", - defaultValue = [], - disabled = false, - emptyText = "暂无可选项", - name, - onValueChange, - options, - placeholder = "请选择", - value, - ...props - }, - ref -) { - const panelId = useId(); - const rootRef = useRef(null); - const triggerRef = useRef(null); - const optionRefs = useRef>([]); - const [internalValue, setInternalValue] = useState(defaultValue); - const [isOpen, setIsOpen] = useState(false); - const isControlled = value !== undefined; - const selectedValues = isControlled ? value : internalValue; - - useEffect(() => { - if (!isOpen) return; - - const handlePointerDown = (event: MouseEvent) => { - const currentRoot = rootRef.current; - if (!currentRoot || currentRoot.contains(event.target as Node)) return; - setIsOpen(false); - }; - - document.addEventListener("mousedown", handlePointerDown); - return () => { - document.removeEventListener("mousedown", handlePointerDown); - }; - }, [isOpen]); - - useEffect(() => { - if (!isOpen) return; - - const firstSelectedIndex = options.findIndex((option) => selectedValues.includes(option.value) && !option.disabled); - const focusIndex = firstSelectedIndex >= 0 ? firstSelectedIndex : options.findIndex((option) => !option.disabled); - optionRefs.current[focusIndex]?.focus(); - }, [isOpen, options, selectedValues]); - - function commitValue(nextValue: string[]) { - if (!isControlled) { - setInternalValue(nextValue); - } - onValueChange?.(nextValue); - } - - function toggleValue(nextOptionValue: string) { - const nextValue = selectedValues.includes(nextOptionValue) - ? selectedValues.filter((selectedValue) => selectedValue !== nextOptionValue) - : [...selectedValues, nextOptionValue]; - - commitValue(nextValue); - } - - function closePanel() { - setIsOpen(false); - triggerRef.current?.focus(); - } - - function handleTriggerKeyDown(event: ReactKeyboardEvent) { - if (disabled) return; - - if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { - event.preventDefault(); - setIsOpen(true); - } - } - - function handleOptionKeyDown(index: number) { - return (event: ReactKeyboardEvent) => { - if (event.key === "ArrowDown") { - event.preventDefault(); - moveFocus(optionRefs.current, index, 1); - } - - if (event.key === "ArrowUp") { - event.preventDefault(); - moveFocus(optionRefs.current, index, -1); - } - - if (event.key === "Escape") { - event.preventDefault(); - closePanel(); - } - }; - } - - function handleClear() { - commitValue([]); - } - - const summary = getMultiSelectSummary(selectedValues, options, placeholder); - const hasSelection = selectedValues.length > 0; - const labelText = typeof ariaLabel === "string" ? ariaLabel : "多选"; - - return ( -
-
- - {hasSelection && !disabled ? ( - - ) : null} -
- - {name - ? selectedValues.map((selectedValue) => ) - : null} - - {isOpen ? ( - - ) : null} -
- ); -}); export function RadioGroupField({ className, diff --git a/apps/web/src/shared/form/MultiSelect.tsx b/apps/web/src/shared/form/MultiSelect.tsx new file mode 100644 index 0000000..a11f3fd --- /dev/null +++ b/apps/web/src/shared/form/MultiSelect.tsx @@ -0,0 +1,214 @@ +import { + forwardRef, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithoutRef, + type KeyboardEvent as ReactKeyboardEvent, + type ReactNode +} from "react"; +import { ChevronDown, X } from "lucide-react"; + +import { Checkbox } from "./FormPrimitives"; +import { cx, getMultiSelectSummary, mergeRefs, moveFocus } from "./internal"; + +type MultiSelectProps = Omit, "defaultValue" | "onChange"> & { + clearLabel?: string; + defaultValue?: string[]; + disabled?: boolean; + emptyText?: ReactNode; + name?: string; + onValueChange?: (nextValue: string[]) => void; + options: MultiSelectOption[]; + placeholder?: ReactNode; + value?: string[]; +}; + + +export type MultiSelectOption = { + value: string; + label: ReactNode; + description?: ReactNode; + disabled?: boolean; +}; + +export const MultiSelect = forwardRef(function MultiSelect( + { + "aria-label": ariaLabel, + className, + clearLabel = "清空选择", + defaultValue = [], + disabled = false, + emptyText = "暂无可选项", + name, + onValueChange, + options, + placeholder = "请选择", + value, + ...props + }, + ref +) { + const panelId = useId(); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const optionRefs = useRef>([]); + const [internalValue, setInternalValue] = useState(defaultValue); + const [isOpen, setIsOpen] = useState(false); + const isControlled = value !== undefined; + const selectedValues = isControlled ? value : internalValue; + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + const currentRoot = rootRef.current; + if (!currentRoot || currentRoot.contains(event.target as Node)) return; + setIsOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const firstSelectedIndex = options.findIndex((option) => selectedValues.includes(option.value) && !option.disabled); + const focusIndex = firstSelectedIndex >= 0 ? firstSelectedIndex : options.findIndex((option) => !option.disabled); + optionRefs.current[focusIndex]?.focus(); + }, [isOpen, options, selectedValues]); + + function commitValue(nextValue: string[]) { + if (!isControlled) { + setInternalValue(nextValue); + } + onValueChange?.(nextValue); + } + + function toggleValue(nextOptionValue: string) { + const nextValue = selectedValues.includes(nextOptionValue) + ? selectedValues.filter((selectedValue) => selectedValue !== nextOptionValue) + : [...selectedValues, nextOptionValue]; + + commitValue(nextValue); + } + + function closePanel() { + setIsOpen(false); + triggerRef.current?.focus(); + } + + function handleTriggerKeyDown(event: ReactKeyboardEvent) { + if (disabled) return; + + if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setIsOpen(true); + } + } + + function handleOptionKeyDown(index: number) { + return (event: ReactKeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveFocus(optionRefs.current, index, 1); + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveFocus(optionRefs.current, index, -1); + } + + if (event.key === "Escape") { + event.preventDefault(); + closePanel(); + } + }; + } + + function handleClear() { + commitValue([]); + } + + const summary = getMultiSelectSummary(selectedValues, options, placeholder); + const hasSelection = selectedValues.length > 0; + const labelText = typeof ariaLabel === "string" ? ariaLabel : "多选"; + + return ( +
+
+ + {hasSelection && !disabled ? ( + + ) : null} +
+ + {name + ? selectedValues.map((selectedValue) => ) + : null} + + {isOpen ? ( + + ) : null} +
+ ); +}); + diff --git a/apps/web/src/shared/form/README.md b/apps/web/src/shared/form/README.md index da1e31d..0847297 100644 --- a/apps/web/src/shared/form/README.md +++ b/apps/web/src/shared/form/README.md @@ -2,6 +2,18 @@ 共享表单原语层。 +## 🗂️ 文件结构 + +`index.ts` 是对外桶导出;`FormPrimitives.tsx` 保留核心原语并 re-export 拆分模块: + +| 文件 | 职责 | +|---|---| +| `FormPrimitives.tsx` | `FormField` / `TextInput` / `SearchInput` / `TextareaInput` / `Checkbox` / `Radio` / `CheckboxField` / `RadioGroupField`,并 re-export 其余模块 | +| `internal.tsx` | 模块内部共享工具:cx、DOM ref 工具、行文本提取、render 辅助 | +| `DateInput.tsx` | `DateInput` / `DateTimeInput` 及日历弹层、日期时间解析 | +| `SelectInput.tsx` | `SelectInput` combobox 触发 + listbox 浮层 | +| `MultiSelect.tsx` | `MultiSelect` 多选面板 | + ## ✅ 放什么 - 字段原语:`FormField`、`TextInput`、`SearchInput`、`SelectInput`、`TextareaInput` - 独立布尔 / 单选原语:`Checkbox`、`Radio` diff --git a/apps/web/src/shared/form/SelectInput.tsx b/apps/web/src/shared/form/SelectInput.tsx new file mode 100644 index 0000000..faed61f --- /dev/null +++ b/apps/web/src/shared/form/SelectInput.tsx @@ -0,0 +1,254 @@ +import { + forwardRef, + useEffect, + useId, + useMemo, + useRef, + useState, + type ChangeEvent, + type KeyboardEvent as ReactKeyboardEvent, + type SelectHTMLAttributes +} from "react"; +import { createPortal } from "react-dom"; +import { Check, ChevronDown } from "lucide-react"; + +import { useFloatingPosition, usePortalRoot } from "../overlay/layer-utils"; +import { + cx, + getSelectOptions, + mergeRefs, + moveFocus, + normalizeSelectValue, + setNativeSelectValue +} from "./internal"; + +export const SelectInput = forwardRef>(function SelectInput( + { + "aria-describedby": ariaDescribedBy, + "aria-label": ariaLabel, + children, + className, + defaultValue, + disabled, + id, + name, + onChange, + required, + value, + ...props + }, + ref +) { + const generatedId = useId(); + const triggerId = id ?? generatedId; + const listboxId = `${triggerId}-listbox`; + const nativeSelectRef = useRef(null); + const triggerRef = useRef(null); + const panelRef = useRef(null); + const optionRefs = useRef>([]); + const portalRoot = usePortalRoot(); + const options = useMemo(() => getSelectOptions(children), [children]); + const fallbackValue = options[0]?.value ?? ""; + const defaultSelectValue = normalizeSelectValue(defaultValue) ?? fallbackValue; + const isControlled = value !== undefined; + const [internalValue, setInternalValue] = useState(defaultSelectValue); + const [isOpen, setIsOpen] = useState(false); + const selectedValue = normalizeSelectValue(isControlled ? value : internalValue) ?? fallbackValue; + const selectedOption = options.find((option) => option.value === selectedValue) ?? options[0]; + const { resolvedSide, style } = useFloatingPosition({ + anchorRef: triggerRef, + contentRef: panelRef, + matchAnchorWidth: true, + open: isOpen, + preferredSide: "bottom" + }); + + useEffect(() => { + if (!isOpen) return; + + const handlePointerDown = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Node)) return; + if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) return; + setIsOpen(false); + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => { + document.removeEventListener("mousedown", handlePointerDown); + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) return; + + const selectedIndex = options.findIndex((option) => option.value === selectedValue && !option.disabled); + const firstEnabledIndex = options.findIndex((option) => !option.disabled); + const focusIndex = selectedIndex >= 0 ? selectedIndex : firstEnabledIndex; + + optionRefs.current[focusIndex]?.focus(); + }, [isOpen, options, selectedValue]); + + function closeListbox() { + setIsOpen(false); + triggerRef.current?.focus(); + } + + function commitValue(nextValue: string) { + if (!isControlled) { + setInternalValue(nextValue); + } + + const nativeSelect = nativeSelectRef.current; + if (nativeSelect) { + setNativeSelectValue(nativeSelect, nextValue); + } + + setIsOpen(false); + triggerRef.current?.focus(); + } + + function handleNativeChange(event: ChangeEvent) { + if (!isControlled) { + setInternalValue(event.currentTarget.value); + } + + onChange?.(event); + } + + function handleTriggerKeyDown(event: ReactKeyboardEvent) { + if (disabled) return; + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setIsOpen(true); + } + } + + function handleOptionKeyDown(index: number) { + return (event: ReactKeyboardEvent) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + moveFocus(optionRefs.current, index, 1); + } + + if (event.key === "ArrowUp") { + event.preventDefault(); + moveFocus(optionRefs.current, index, -1); + } + + if (event.key === "Home") { + event.preventDefault(); + optionRefs.current.find((option) => option && !option.disabled)?.focus(); + } + + if (event.key === "End") { + event.preventDefault(); + [...optionRefs.current].reverse().find((option) => option && !option.disabled)?.focus(); + } + + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + const option = options[index]; + if (!option || option.disabled) return; + commitValue(option.value); + } + + if (event.key === "Escape") { + event.preventDefault(); + closeListbox(); + } + }; + } + + return ( +
+ + + + + {isOpen && portalRoot + ? createPortal( +
+ {options.map((option, index) => { + const isSelected = option.value === selectedValue; + + return ( + + ); + })} +
, + portalRoot + ) + : null} +
+ ); +}); + diff --git a/apps/web/src/shared/form/index.ts b/apps/web/src/shared/form/index.ts index c7acd6f..7f08bf4 100644 --- a/apps/web/src/shared/form/index.ts +++ b/apps/web/src/shared/form/index.ts @@ -1,14 +1,15 @@ export { Checkbox, CheckboxField, - DateInput, - DateTimeInput, FormField, - MultiSelect, Radio, RadioGroupField, SearchInput, - SelectInput, TextInput, TextareaInput } from "./FormPrimitives"; +export { DateInput, DateTimeInput } from "./DateInput"; +export type { DateInputProps, DateTimeInputProps } from "./DateInput"; +export { SelectInput } from "./SelectInput"; +export { MultiSelect } from "./MultiSelect"; +export type { MultiSelectOption } from "./MultiSelect"; diff --git a/apps/web/src/shared/form/internal.tsx b/apps/web/src/shared/form/internal.tsx new file mode 100644 index 0000000..ff5f3f2 --- /dev/null +++ b/apps/web/src/shared/form/internal.tsx @@ -0,0 +1,218 @@ +import { + Children, + isValidElement, + type ComponentPropsWithoutRef, + type ReactElement, + type ReactNode, + type Ref, + type SelectHTMLAttributes +} from "react"; + +export type SelectOptionRecord = { + disabled?: boolean; + label: ReactNode; + textValue: string; + value: string; +}; + +export type FormTone = "default" | "error" | "success"; + +export function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +export function mergeIds(...values: Array) { + return values.filter(Boolean).join(" ") || undefined; +} + +export function assignRef(ref: Ref | undefined, value: T) { + if (!ref) return; + if (typeof ref === "function") { + ref(value); + return; + } + ref.current = value; +} + +export function mergeRefs(...refs: Array | undefined>) { + return (value: T) => { + refs.forEach((ref) => assignRef(ref, value)); + }; +} + +export function setNativeInputValue(input: HTMLInputElement, nextValue: string) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value"); + descriptor?.set?.call(input, nextValue); + input.dispatchEvent(new Event("input", { bubbles: true })); +} + +export function setNativeSelectValue(select: HTMLSelectElement, nextValue: string) { + const descriptor = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value"); + descriptor?.set?.call(select, nextValue); + select.dispatchEvent(new Event("change", { bubbles: true })); +} + +export function getTextValue(node: ReactNode): string { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + + if (Array.isArray(node)) { + return node.map(getTextValue).join(""); + } + + if (isValidElement<{ children?: ReactNode }>(node)) { + return getTextValue(node.props.children); + } + + return ""; +} + +export function getSelectOptions(children: ReactNode): SelectOptionRecord[] { + return Children.toArray(children) + .filter((child): child is ReactElement> => { + return isValidElement>(child) && child.type === "option"; + }) + .map((child) => { + const label = child.props.children; + const textValue = getTextValue(label); + const value = child.props.value === undefined ? textValue : String(child.props.value); + + return { + disabled: child.props.disabled, + label, + textValue, + value + }; + }); +} + +export function normalizeSelectValue(value: SelectHTMLAttributes["value"] | SelectHTMLAttributes["defaultValue"]) { + if (Array.isArray(value)) return String(value[0] ?? ""); + if (value === undefined || value === null) return undefined; + return String(value); +} + +export function renderFieldMeta( + description?: ReactNode, + descriptionId?: string, + message?: ReactNode, + messageId?: string, + tone: FormTone = "default" +) { + return ( + <> + {description ? ( + + {description} + + ) : null} + {message ? ( + + {message} + + ) : null} + + ); +} + +export function renderFieldLabel(label: ReactNode, required?: boolean) { + return ( + + {label} + {required ? ( + + ) : null} + + ); +} + +export type FormCheckBaseProps = Omit, "children" | "type"> & { + description?: ReactNode; + inputClassName?: string; + label: ReactNode; + variant?: "inline" | "card"; +}; + +export function renderFormCheck( + type: "checkbox" | "radio", + inputId: string, + { + className, + description, + inputClassName, + label, + variant = "inline", + ...props + }: FormCheckBaseProps, + ref: Ref +) { + const descriptionId = description ? `${inputId}-description` : undefined; + const isChecked = props.checked ?? props.defaultChecked; + + return ( +
+ + + + {description ? ( + + {description} + + ) : null} + +
+ ); +} + + +export function getMultiSelectSummary(selectedValues: string[], options: { value: string; label: ReactNode }[], placeholder: ReactNode) { + if (selectedValues.length === 0) return placeholder; + + const labels = selectedValues + .map((selectedValue) => options.find((option) => option.value === selectedValue)?.label) + .filter((label): label is ReactNode => label !== undefined); + + return labels.map((label, index) => ( + + {index > 0 ? ", " : null} + {label} + + )); +} + +export function moveFocus(optionRefs: Array, startIndex: number, step: 1 | -1) { + const total = optionRefs.length; + let cursor = startIndex; + + for (let attempts = 0; attempts < total; attempts += 1) { + cursor = (cursor + step + total) % total; + const next = optionRefs[cursor]; + + if (next && !next.hasAttribute("disabled") && next.getAttribute("aria-disabled") !== "true") { + next.focus(); + return; + } + } +} + diff --git a/apps/web/src/test/api-client.test.ts b/apps/web/src/test/api-client.test.ts index 038d159..bb3e83a 100644 --- a/apps/web/src/test/api-client.test.ts +++ b/apps/web/src/test/api-client.test.ts @@ -113,4 +113,59 @@ describe("api client", () => { await expect(apiFetch("/api/announcements/ann-1", { method: "DELETE" })).resolves.toBeUndefined(); expect(fetchSpy).toHaveBeenCalledTimes(1); }); + + it("retries its own request when a joined in-flight GET is aborted by another caller", async () => { + // Polling tick (first) and a manual refresh (second) share one URL; the + // second caller must not inherit the first caller's AbortError through the + // deduped promise. + const firstController = new AbortController(); + const secondController = new AbortController(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockImplementationOnce((_url: unknown, init?: RequestInit) => pendingUntilAborted(init?.signal)) + .mockImplementationOnce(() => jsonResponse({ ok: true })); + + const firstPayload = apiFetch<{ ok: boolean }>("/api/mail/messages?page=1", { + signal: firstController.signal + }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const secondPayload = apiFetch<{ ok: boolean }>("/api/mail/messages?page=1", { + signal: secondController.signal + }); + + firstController.abort(); + + await expect(firstPayload).rejects.toMatchObject({ name: "AbortError" }); + await expect(secondPayload).resolves.toEqual({ ok: true }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it("rejects a joined caller whose own signal aborts, without retrying", async () => { + const firstController = new AbortController(); + const secondController = new AbortController(); + vi.spyOn(globalThis, "fetch").mockImplementation((_url: unknown, init?: RequestInit) => + pendingUntilAborted(init?.signal) + ); + + const firstPayload = apiFetch("/api/mail/messages?page=2", { signal: firstController.signal }); + await new Promise((resolve) => setTimeout(resolve, 10)); + const secondPayload = apiFetch("/api/mail/messages?page=2", { signal: secondController.signal }); + + secondController.abort(); + + await expect(secondPayload).rejects.toMatchObject({ name: "AbortError" }); + firstController.abort(); + await expect(firstPayload).rejects.toMatchObject({ name: "AbortError" }); + }); }); + +function pendingUntilAborted(signal?: AbortSignal | null): Promise { + return new Promise((_resolve, reject) => { + const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" }); + if (signal?.aborted) { + reject(abortError); + return; + } + signal?.addEventListener("abort", () => reject(abortError), { once: true }); + }); +} diff --git a/apps/worker/package.json b/apps/worker/package.json index 633f7b2..40283c8 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1,6 +1,6 @@ { "name": "@wemail/worker", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "scripts": { diff --git a/apps/worker/src/app/create-app.ts b/apps/worker/src/app/create-app.ts index de591a4..35cc652 100644 --- a/apps/worker/src/app/create-app.ts +++ b/apps/worker/src/app/create-app.ts @@ -11,13 +11,33 @@ import { getApiKeyAuth, getSessionAuth, resolveFeatureToggles } from "./services import { resolveStore } from "./services/store-service"; import { processInboundEmail, runCleanup } from "./runtime"; +const LOCAL_DEV_ORIGINS = ["http://127.0.0.1:5173", "http://localhost:5173"] as const; + +// Endpoints that accept unauthenticated or credential-sensitive traffic. The +// Cloudflare rate limiter namespace only sees these paths, so new entry points +// with the same risk profile must be added here. +const RATE_LIMITED_PATHS = new Set([ + "/api/auth/register", + "/api/auth/login", + "/api/accounts", + "/api/mail/send" +]); + +function isRateLimitedRequest(method: string, path: string) { + if (RATE_LIMITED_PATHS.has(path)) return true; + if (path === "/api/api-keys" && method === "POST") return true; + // OAuth start/callback/finalize exchange tokens and create sessions; treat + // them like login for brute-force protection. + return path.startsWith("/api/auth/oauth/"); +} + function resolveCorsOrigin(env: AppContext["Bindings"], origin?: string) { if (!origin) return undefined; - if (origin === "http://127.0.0.1:5173" || origin === "http://localhost:5173") { + const { cors, environment } = resolveAppConfig(env); + if (environment === "local" && (LOCAL_DEV_ORIGINS as readonly string[]).includes(origin)) { return origin; } - const { allowedOrigins } = resolveAppConfig(env).cors; - return allowedOrigins.includes(origin) ? origin : undefined; + return cors.allowedOrigins.includes(origin) ? origin : undefined; } function resolveApiKeyScopeRequirement(method: string, path: string): ApiKeyScope | null { @@ -72,13 +92,7 @@ export function createApp(options?: { store?: AppContext["Variables"]["store"] } if (c.env.RATE_LIMITER) { const ip = c.req.header("cf-connecting-ip") ?? "local"; - const limited = [ - "/api/auth/register", - "/api/auth/login", - "/api/accounts", - "/api/mail/send" - ].includes(c.req.path) || (c.req.path === "/api/api-keys" && c.req.method === "POST"); - if (limited) { + if (isRateLimitedRequest(c.req.method, c.req.path)) { const result = await c.env.RATE_LIMITER.limit({ key: `${c.req.path}:${ip}` }); if (!result.success) return jsonError("Rate limit exceeded", 429); } diff --git a/apps/worker/src/app/runtime.ts b/apps/worker/src/app/runtime.ts index 7fd3fd3..72fdd10 100644 --- a/apps/worker/src/app/runtime.ts +++ b/apps/worker/src/app/runtime.ts @@ -257,24 +257,53 @@ export async function processInboundEmail( return processInboundForMailbox(store, env, mailbox, toAddress, parsed); } +// Cleanup must finish within one cron invocation; bounding each phase keeps the +// run predictable while still draining the full backlog in one pass. +const CLEANUP_BATCH_SIZE = 500; +const R2_DELETE_CONCURRENCY = 10; + +async function deleteR2ObjectsConcurrently( + bucket: NonNullable, + keys: string[] +) { + for (let offset = 0; offset < keys.length; offset += R2_DELETE_CONCURRENCY) { + await Promise.all(keys.slice(offset, offset + R2_DELETE_CONCURRENCY).map((key) => bucket.delete(key))); + } +} + export async function runCleanup(store: AppStore, env: AppBindings) { const startedAt = new Date().toISOString(); try { - const expired = await store.messages.listExpired(new Date().toISOString()); - const expiredIds = expired.map((entry) => entry.id); - const attachments = await store.attachments.listByMessageIds(expiredIds); - const accountPolicy = parseAccountPolicyRecord(await store.accountSettings.get()); - let deletedAccounts = 0; + let deletedMessages = 0; + let deletedAttachments = 0; + let hasMoreExpiredMessages = true; + + while (hasMoreExpiredMessages) { + // Bounded batches so a large backlog cannot produce unbounded queries or + // a single over-long cron execution. IN (...) statement size is bounded + // separately by chunkForD1BindLimit inside the D1 store helpers. + const expired = await store.messages.listExpired(new Date().toISOString(), { + limit: CLEANUP_BATCH_SIZE + }); + hasMoreExpiredMessages = expired.length === CLEANUP_BATCH_SIZE; + if (expired.length === 0) break; + + const expiredIds = expired.map((entry) => entry.id); + const attachments = await store.attachments.listByMessageIds(expiredIds); - if (env.ATTACHMENTS) { - for (const attachment of attachments) { - await env.ATTACHMENTS.delete(attachment.key); + if (env.ATTACHMENTS) { + await deleteR2ObjectsConcurrently(env.ATTACHMENTS, attachments.map((attachment) => attachment.key)); } + + await store.attachments.deleteByMessageIds(expiredIds); + await store.messages.deleteMany(expiredIds); + deletedMessages += expiredIds.length; + deletedAttachments += attachments.length; } - await store.attachments.deleteByMessageIds(expiredIds); - await store.messages.deleteMany(expiredIds); + const accountPolicy = parseAccountPolicyRecord(await store.accountSettings.get()); + let deletedAccounts = 0; if (accountPolicy.lifecycle.allowHardDelete) { const cutoff = new Date( @@ -308,7 +337,7 @@ export async function runCleanup(store: AppStore, env: AppBindings) { deletedAccounts = expiredAccountIds.length; } - const result = { deletedMessages: expiredIds.length, deletedAttachments: attachments.length, deletedAccounts }; + const result = { deletedMessages, deletedAttachments, deletedAccounts }; await store.cleanupRuns.record({ status: "success", startedAt, diff --git a/apps/worker/src/app/services/session-service.ts b/apps/worker/src/app/services/session-service.ts index 0da784b..c7b6d99 100644 --- a/apps/worker/src/app/services/session-service.ts +++ b/apps/worker/src/app/services/session-service.ts @@ -3,6 +3,18 @@ import { resolveAppConfig } from "../../core/config"; import { hashString, readSessionCookies } from "../../shared/auth"; import { CACHE_KEYS, CACHE_TTL_SECONDS, cachedJson } from "./cache-service"; +// Minimum gap between session last-seen writes. See getSessionAuth. +const SESSION_TOUCH_INTERVAL_MS = 60_000; + +// The persisted touch uses COALESCE so null fields keep existing values; mirror +// that behavior when synthesizing the returned session record. +function pruneSessionMetadata(metadata: { userAgent?: string | null; ipAddress?: string | null }) { + const next: { userAgent?: string | null; ipAddress?: string | null } = {}; + if (metadata.userAgent != null) next.userAgent = metadata.userAgent; + if (metadata.ipAddress != null) next.ipAddress = metadata.ipAddress; + return next; +} + export function sessionExpiryIso(env?: Pick) { const expires = new Date(); const ttlHours = env ? resolveAppConfig(env as AppBindings).session.ttlHours : 72; @@ -39,9 +51,18 @@ export async function getSessionAuth(c: any, store: AppStore) { } const user = await store.users.findById(session.userId); if (user?.status !== "active") continue; - await store.sessions.touch(session.id, getRequestSessionMetadata(c)); - const refreshedSession = await store.sessions.findById(session.id); - return { user, session: refreshedSession ?? session }; + + // Throttle the last-seen write: without this, every authenticated request + // performs an UPDATE plus a re-read. Metadata drift of up to one minute is + // acceptable for device listings. + const shouldTouch = Date.now() - new Date(session.lastSeenAt).getTime() >= SESSION_TOUCH_INTERVAL_MS; + const touchedSession = shouldTouch + ? { ...session, ...pruneSessionMetadata(getRequestSessionMetadata(c)) } + : session; + if (shouldTouch) { + await store.sessions.touch(session.id, getRequestSessionMetadata(c)); + } + return { user, session: touchedSession }; } return null; } diff --git a/apps/worker/src/core/bindings.ts b/apps/worker/src/core/bindings.ts index f3a9d17..e7d379e 100644 --- a/apps/worker/src/core/bindings.ts +++ b/apps/worker/src/core/bindings.ts @@ -590,7 +590,9 @@ export interface AppStore { listForMailboxes: (query: MessageRecordListQuery) => Promise; listByMailbox: (mailboxId: string) => Promise; findById: (id: string) => Promise; - listExpired: (beforeIso: string) => Promise; + // options.limit bounds the query; omitting it keeps the historical + // unbounded semantics used by diagnostics endpoints. + listExpired: (beforeIso: string, options?: { limit?: number }) => Promise; deleteMany: (ids: string[]) => Promise; }; attachments: { diff --git a/apps/worker/src/infrastructure/db/migrations/0020-message-expiry-cleanup-index.sql b/apps/worker/src/infrastructure/db/migrations/0020-message-expiry-cleanup-index.sql new file mode 100644 index 0000000..47354e8 --- /dev/null +++ b/apps/worker/src/infrastructure/db/migrations/0020-message-expiry-cleanup-index.sql @@ -0,0 +1,5 @@ +-- Cleanup cron scans mail_messages by expiry, so the column needs an index to +-- avoid a full-table scan on every scheduled run. Includes id as an explicit +-- tiebreaker for stable ordering. +CREATE INDEX IF NOT EXISTS idx_mail_messages_expires_at + ON mail_messages (expires_at, id); diff --git a/apps/worker/src/infrastructure/db/migrations/0021-normalize-announcement-empty-dates.sql b/apps/worker/src/infrastructure/db/migrations/0021-normalize-announcement-empty-dates.sql new file mode 100644 index 0000000..0c52f35 --- /dev/null +++ b/apps/worker/src/infrastructure/db/migrations/0021-normalize-announcement-empty-dates.sql @@ -0,0 +1,7 @@ +-- Normalizes announcement date columns written before empty strings were +-- rejected at the API boundary. A stored '' makes the SQL visibility +-- predicates disagree with the JS falsy handling (e.g. end_at = '' hides the +-- row from the board list while the detail endpoint still returns it). +-- Idempotent and backward compatible: NULL-or-valid-ISO rows are untouched. +UPDATE announcements SET start_at = NULL WHERE start_at = ''; +UPDATE announcements SET end_at = NULL WHERE end_at = ''; diff --git a/apps/worker/src/infrastructure/persistence/README.md b/apps/worker/src/infrastructure/persistence/README.md index 603e84e..d9c66cb 100644 --- a/apps/worker/src/infrastructure/persistence/README.md +++ b/apps/worker/src/infrastructure/persistence/README.md @@ -10,3 +10,19 @@ ## 🚫 不放什么 - 路由逻辑 - 页面响应层 + +## 🗂️ D1 store 结构 + +`d1.ts` 是唯一公共入口(`createD1Store`),按聚合组合 `d1/` 下的模块: + +| 模块 | 职责 | +|---|---| +| `d1/shared.ts` | 跨模块纯辅助:分页、布尔/JSON 解析、scope 解析 | +| `d1/row-mappers.ts` | 数据库行 → 领域记录的映射函数 | +| `d1/users.ts` | users / userPreferences / sessions / oauth* / invites / apiKeys | +| `d1/mail.ts` | mailboxes / messages / attachments / outboundMessages | +| `d1/settings.ts` | settings / runtimeSettings / mailDomains / dictionaries / accountSettings / mailSettings / quotas | +| `d1/ops.ts` | audit / cleanupRuns / telegram / webhook* / notificationRules | +| `d1/announcements.ts` | 公告聚合;SQL 过滤构造见 `announcement-sql.ts` | + +新增聚合时:在 `d1/` 建模块、导出 `createAggregate(db)`,并在 `d1.ts` 组装。行映射统一放 `row-mappers.ts`。`in-memory.ts` 与 D1 实现保持接口对等,由集成测试约束。 diff --git a/apps/worker/src/infrastructure/persistence/d1.ts b/apps/worker/src/infrastructure/persistence/d1.ts index 81dc373..65a2c81 100644 --- a/apps/worker/src/infrastructure/persistence/d1.ts +++ b/apps/worker/src/infrastructure/persistence/d1.ts @@ -1,2184 +1,21 @@ -import { - API_KEY_SCOPE_DEFINITIONS, - applyDictionaryItemUpdate, - buildDictionaryCatalog, - DEFAULT_API_KEY_SCOPES, - findDefaultDictionaryGroup, - findDefaultDictionaryItem, - type ApiKeyScope, - type DictionaryGroupSummary, - type DictionaryItemSummary, - type MailDomainSummary, - type UserRole, - type UserStatus -} from "@wemail/shared"; - -import type { - AnnouncementRecord, - AnnouncementReceiptRecord, - AppStore, - AttachmentRecord, - MailboxDetailListQuery, - MailboxDetailRecord, - NotificationRuleRecord, - OAuthIdentityRecord, - OAuthPendingLoginRecord, - OAuthProviderId, - OAuthStateRecord, - OutboundMessageRecord, - OutboundMessageUsageQuery, - OutboundMessageUsageSummary, - PersistedMessageRecord, - QuotaRecord, - RuntimeSettingsRecord, - SessionRecord, - UserPreferencesRecord -} from "../../core/bindings"; -import { - filterAnnouncements, - getAnnouncementSummary, - getFeaturedAnnouncements, - paginateAnnouncements -} from "../../shared/announcements"; - -function nowIso() { - return new Date().toISOString(); -} - -const DAY_MS = 24 * 60 * 60 * 1000; - -function getSafePage(value: number) { - return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 1; -} - -function getSafePageSize(value: number) { - if (!Number.isFinite(value) || value < 1) return 10; - return Math.min(Math.trunc(value), 500); -} - -function getActiveRangeDays(value: MailboxDetailListQuery["activeRange"]) { - switch (value) { - case "7d": - return 7; - case "30d": - return 30; - case "90d": - return 90; - default: - return null; - } -} - -function getInactiveDays(value: number | undefined) { - return Number.isFinite(value) && value && value > 0 ? Math.trunc(value) : 30; -} - -function toBool(value: unknown) { - return value === 1 || value === "1" || value === true; -} - -function parseJson(value: string | null | undefined, fallback: T) { - if (!value) return fallback; - return JSON.parse(value) as T; -} - -function parseAllowedRoles(value: string | null | undefined): UserRole[] { - const roles = parseJson(value, []); - return roles.filter((role): role is UserRole => role === "admin" || role === "member"); -} - -const apiKeyScopeIds = new Set(API_KEY_SCOPE_DEFINITIONS.map((scope) => scope.id)); - -function parseApiKeyScopes(value: string | null | undefined): ApiKeyScope[] { - const scopes = parseJson(value, [...DEFAULT_API_KEY_SCOPES]); - const normalized = scopes.filter((scope): scope is ApiKeyScope => typeof scope === "string" && apiKeyScopeIds.has(scope)); - return normalized.length > 0 ? normalized : [...DEFAULT_API_KEY_SCOPES]; -} - -function toDictionaryGroup(row: any): DictionaryGroupSummary { - return { - groupKey: row.group_key, - label: row.label, - description: row.description, - isSystem: toBool(row.is_system), - version: Number(row.version || 1), - updatedAt: row.updated_at - }; -} - -function toDictionaryItem(row: any): DictionaryItemSummary { - return { - groupKey: row.group_key, - value: row.value, - label: row.label, - description: row.description, - sortOrder: Number(row.sort_order || 0), - enabled: toBool(row.enabled), - metadata: parseJson>(row.metadata_json, {}), - updatedAt: row.updated_at - }; -} - -function parseOAuthProvider(value: unknown): OAuthProviderId { - return value === "linuxdo" ? "linuxdo" : "github"; -} - -function toOAuthIdentityRecord(row: any): OAuthIdentityRecord { - return { - id: row.id, - userId: row.user_id, - provider: parseOAuthProvider(row.provider), - providerUserId: row.provider_user_id, - providerEmail: row.provider_email, - providerLogin: row.provider_login ?? null, - createdAt: row.created_at, - updatedAt: row.updated_at - }; -} - -function toOAuthStateRecord(row: any): OAuthStateRecord { - return { - id: row.id, - provider: parseOAuthProvider(row.provider), - redirectTo: row.redirect_to, - expiresAt: row.expires_at, - createdAt: row.created_at - }; -} - -function toOAuthPendingLoginRecord(row: any): OAuthPendingLoginRecord { - return { - id: row.id, - provider: parseOAuthProvider(row.provider), - providerUserId: row.provider_user_id, - providerEmail: row.provider_email, - providerName: row.provider_name, - providerLogin: row.provider_login ?? null, - redirectTo: row.redirect_to, - expiresAt: row.expires_at, - createdAt: row.created_at - }; -} - -function parseUserStatus(value: unknown): UserStatus { - return value === "disabled" || value === "outbound_disabled" ? "disabled" : "active"; -} - -function parseUserRole(value: unknown): UserRole { - return value === "admin" ? "admin" : "member"; -} - -function toMessageRecord(row: any): PersistedMessageRecord { - return { - id: row.id, - mailboxId: row.account_id, - toAddress: row.to_address ?? null, - fromAddress: row.from_address, - subject: row.subject, - previewText: row.preview_text, - bodyText: row.body_text, - extractionJson: row.extraction_json, - oversizeStatus: row.oversize_status, - attachmentCount: Number(row.attachment_count), - receivedAt: row.received_at, - expiresAt: row.expires_at - }; -} - -function toNotificationRuleRecord(row: any): NotificationRuleRecord { - return { - id: row.id, - userId: row.user_id, - name: row.name, - enabled: Boolean(row.enabled), - target: row.target, - targetId: row.target_id ?? null, - eventTypesJson: row.event_types_json, - mailboxIdsJson: row.mailbox_ids_json, - keyword: row.keyword, - quietHoursStart: row.quiet_hours_start, - quietHoursEnd: row.quiet_hours_end, - createdAt: row.created_at, - updatedAt: row.updated_at - }; -} - -function toAnnouncementRecord(row: any): AnnouncementRecord { - return { - id: row.id, - title: row.title, - summary: row.summary, - type: row.type, - status: row.status, - audience: row.audience, - priority: row.priority, - authorUserId: row.author_user_id, - authorLabel: row.author_label, - tagsJson: row.tags_json, - pinned: toBool(row.pinned), - startAt: row.start_at, - endAt: row.end_at, - publishedAt: row.published_at, - updatedAt: row.updated_at - }; -} - -function toAnnouncementReceiptRecord(row: any): AnnouncementReceiptRecord { - return { - announcementId: row.announcement_id, - userId: row.user_id, - acknowledgedAt: row.acknowledged_at - }; -} - -function toOutboundMessageRecord(row: any): OutboundMessageRecord { - return { - id: row.id, - mailboxId: row.account_id, - fromAddress: row.from_address ?? "", - toAddress: row.to_address, - subject: row.subject, - bodyText: row.body_text ?? "", - status: row.status === "failed" ? "failed" : "sent", - errorText: row.error_text, - providerMessageId: row.provider_message_id ?? null, - requestPayloadJson: row.request_payload_json ?? "{}", - responsePayloadJson: row.response_payload_json ?? null, - createdAt: row.created_at - }; -} - -function toUserRecord(row: any) { - const email = String(row.email); - return { - id: row.id, - email, - name: row.name || email.split("@")[0] || email, - passwordHash: row.password_hash, - role: row.role, - status: parseUserStatus(row.status), - createdAt: row.created_at, - updatedAt: row.updated_at || row.created_at - }; -} - -function toUserPreferencesRecord(row: any): UserPreferencesRecord { - return { - userId: row.user_id, - bio: row.bio || "", - locale: row.locale || "zh-CN", - timezone: row.timezone || "Asia/Shanghai", - dateFormat: row.date_format || "yyyy-mm-dd", - landingPage: row.landing_page || "/dashboard", - density: row.density || "comfortable", - updatedAt: row.updated_at - }; -} - -function toSessionRecord(row: any): SessionRecord { - return { - id: row.id, - userId: row.user_id, - userAgent: row.user_agent ?? null, - ipAddress: row.ip_address ?? null, - expiresAt: row.expires_at, - lastSeenAt: row.last_seen_at ?? row.created_at, - createdAt: row.created_at - }; -} - -function toInviteRecord(row: any) { - return { - id: row.id, - code: row.code, - createdByUserId: row.created_by_user_id, - redeemedByUserId: row.redeemed_by_user_id, - redeemedByUserName: row.redeemed_by_user_name || row.redeemed_by_user_email || null, - redeemedAt: row.redeemed_at, - disabledAt: row.disabled_at, - expiresAt: row.expires_at ?? null, - targetRole: parseUserRole(row.target_role), - maxRedemptions: Number(row.max_redemptions ?? 1), - redemptionCount: Number(row.redemption_count ?? (row.redeemed_at ? 1 : 0)), - createdAt: row.created_at - }; -} - -function toApiKeyRecord(row: any) { - return { - id: row.id, - userId: row.user_id, - label: row.label, - prefix: row.prefix, - scopes: parseApiKeyScopes(row.scopes_json), - keyHash: row.key_hash, - createdAt: row.created_at, - lastUsedAt: row.last_used_at, - revokedAt: row.revoked_at - }; -} - -function toMailboxRecord(row: any) { - return { - id: row.id, - userId: row.user_id, - address: row.address, - label: row.label, - createdAt: row.created_at - }; -} - -function toMailboxDetailRecord(row: any): MailboxDetailRecord { - return { - id: row.id, - userId: row.user_id, - address: row.address, - label: row.label, - status: row.status || "enabled", - tags: JSON.parse(row.tags_json || "[]"), - createdBy: row.created_by_user_id, - createdByName: row.created_by_name, - lastActiveAt: row.last_active_at, - deletedAt: row.deleted_at, - messageCount: Number(row.message_count || 0), - outboundCount: Number(row.outbound_count || 0), - createdAt: row.created_at - }; -} - +import type { D1Database } from "@cloudflare/workers-types"; + +import type { AppStore } from "../../core/bindings"; +import { createAnnouncementsAggregate } from "./d1/announcements"; +import { createMailAggregate } from "./d1/mail"; +import { createOpsAggregate } from "./d1/ops"; +import { createSettingsAggregate } from "./d1/settings"; +import { createUsersAggregate } from "./d1/users"; + +// The D1 store is composed from per-aggregate modules under ./d1. Each module +// owns the SQL, row mapping, and query-shaping logic for its domain; this file +// is only the assembly point that preserves the single AppStore entry. export function createD1Store(db: D1Database): AppStore { - async function findMailboxDetailById(id: string) { - const row = await db - .prepare( - ` - SELECT - a.id, - a.user_id, - a.address, - a.label, - a.status, - a.tags_json, - a.created_by_user_id, - a.last_active_at, - a.deleted_at, - a.created_at, - u.name as created_by_name, - (SELECT COUNT(*) FROM mail_messages WHERE account_id = a.id) as message_count, - (SELECT COUNT(*) FROM mail_outbound_messages WHERE account_id = a.id) as outbound_count - FROM accounts a - LEFT JOIN users u ON a.created_by_user_id = u.id - WHERE a.id = ? - ` - ) - .bind(id) - .first(); - - return row ? toMailboxDetailRecord(row) : null; - } - return { - users: { - async count() { - const result = await db.prepare("SELECT count(*) AS count FROM users").first<{ count: number }>(); - return result?.count ?? 0; - }, - async countActiveByRole(role) { - const statement = role - ? db.prepare("SELECT count(*) AS count FROM users WHERE status = 'active' AND role = ?").bind(role) - : db.prepare("SELECT count(*) AS count FROM users WHERE status = 'active'"); - const result = await statement.first<{ count: number }>(); - return result?.count ?? 0; - }, - async findByEmail(email) { - const row = await db.prepare("SELECT * FROM users WHERE email = ?").bind(email).first(); - return row ? toUserRecord(row) : null; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM users WHERE id = ?").bind(id).first(); - return row ? toUserRecord(row) : null; - }, - async create(input) { - const id = crypto.randomUUID(); - const createdAt = nowIso(); - await db - .prepare( - "INSERT INTO users (id, email, name, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)" - ) - .bind(id, input.email, input.name, input.passwordHash, input.role, createdAt, createdAt) - .run(); - return { - id, - email: input.email, - name: input.name, - passwordHash: input.passwordHash, - role: input.role, - status: "active", - createdAt, - updatedAt: createdAt - }; - }, - async updateProfile(id, input) { - await db.prepare("UPDATE users SET name = ?, updated_at = ? WHERE id = ?").bind(input.name, nowIso(), id).run(); - return this.findById(id); - }, - async updateRole(id, role) { - await db.prepare("UPDATE users SET role = ?, updated_at = ? WHERE id = ?").bind(role, nowIso(), id).run(); - return this.findById(id); - }, - async updatePasswordHash(id, passwordHash) { - await db.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?").bind(passwordHash, nowIso(), id).run(); - return this.findById(id); - }, - async updateStatus(id, status) { - await db.prepare("UPDATE users SET status = ?, updated_at = ? WHERE id = ?").bind(status, nowIso(), id).run(); - return this.findById(id); - }, - async delete(id) { - const existing = await this.findById(id); - if (!existing) return false; - await db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM oauth_identities WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM user_send_quotas WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM api_keys WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM telegram_subscriptions WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM user_preferences WHERE user_id = ?").bind(id).run(); - await db.prepare("DELETE FROM users WHERE id = ?").bind(id).run(); - return true; - }, - async list(options) { - const whereParts: string[] = []; - const bindings: Array = []; - const normalizedSearch = options.search?.toLowerCase(); - - if (normalizedSearch) { - whereParts.push("(lower(email) LIKE ? OR lower(name) LIKE ?)"); - bindings.push(`%${normalizedSearch}%`, `%${normalizedSearch}%`); - } - if (options.role) { - whereParts.push("role = ?"); - bindings.push(options.role); - } - if (options.status) { - whereParts.push("status = ?"); - bindings.push(options.status); - } - - const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : ""; - const totalRow = await db - .prepare(`SELECT count(*) AS count FROM users${whereSql}`) - .bind(...bindings) - .first<{ count: number }>(); - const result = await db - .prepare(`SELECT * FROM users${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`) - .bind(...bindings, options.pageSize, (options.page - 1) * options.pageSize) - .all(); - - return { - users: (result.results ?? []).map(toUserRecord), - total: totalRow?.count ?? 0, - page: options.page, - pageSize: options.pageSize - }; - }, - async summary() { - const row = await db - .prepare( - "SELECT count(*) AS total, sum(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active FROM users" - ) - .first<{ active: number | null; total: number }>(); - return { - active: row?.active ?? 0, - total: row?.total ?? 0 - }; - } - }, - userPreferences: { - async getByUserId(userId) { - const row = await db.prepare("SELECT * FROM user_preferences WHERE user_id = ?").bind(userId).first(); - return row ? toUserPreferencesRecord(row) : null; - }, - async save(record) { - const updatedAt = nowIso(); - await db - .prepare( - "INSERT OR REPLACE INTO user_preferences (user_id, bio, locale, timezone, date_format, landing_page, density, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - record.userId, - record.bio, - record.locale, - record.timezone, - record.dateFormat, - record.landingPage, - record.density, - updatedAt - ) - .run(); - return { - ...record, - updatedAt - }; - } - }, - sessions: { - async create(input) { - const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; - const createdAt = nowIso(); - await db - .prepare( - "INSERT INTO auth_sessions (id, user_id, user_agent, ip_address, expires_at, last_seen_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" - ) - .bind(id, input.userId, input.userAgent ?? null, input.ipAddress ?? null, input.expiresAt, createdAt, createdAt) - .run(); - return { - id, - userId: input.userId, - userAgent: input.userAgent ?? null, - ipAddress: input.ipAddress ?? null, - expiresAt: input.expiresAt, - lastSeenAt: createdAt, - createdAt - }; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM auth_sessions WHERE id = ?").bind(id).first(); - return row ? toSessionRecord(row) : null; - }, - async listByUser(userId) { - const result = await db - .prepare("SELECT * FROM auth_sessions WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, created_at DESC") - .bind(userId) - .all(); - return (result.results ?? []).map(toSessionRecord); - }, - async touch(id, input = {}) { - const lastSeenAt = nowIso(); - await db - .prepare( - ` - UPDATE auth_sessions - SET - last_seen_at = ?, - user_agent = COALESCE(?, user_agent), - ip_address = COALESCE(?, ip_address) - WHERE id = ? - ` - ) - .bind(lastSeenAt, input.userAgent ?? null, input.ipAddress ?? null, id) - .run(); - }, - async delete(id) { - await db.prepare("DELETE FROM auth_sessions WHERE id = ?").bind(id).run(); - }, - async deleteByUserId(userId) { - await db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(userId).run(); - }, - async deleteByUserIdExcept(userId, keepSessionId) { - await db.prepare("DELETE FROM auth_sessions WHERE user_id = ? AND id != ?").bind(userId, keepSessionId).run(); - } - }, - oauthStates: { - async create(input) { - const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; - const createdAt = nowIso(); - await db - .prepare("INSERT INTO oauth_states (id, provider, redirect_to, expires_at, created_at) VALUES (?, ?, ?, ?, ?)") - .bind(id, input.provider, input.redirectTo, input.expiresAt, createdAt) - .run(); - return { id, provider: input.provider, redirectTo: input.redirectTo, expiresAt: input.expiresAt, createdAt }; - }, - async consume(id) { - const row = await db.prepare("SELECT * FROM oauth_states WHERE id = ?").bind(id).first(); - await db.prepare("DELETE FROM oauth_states WHERE id = ?").bind(id).run(); - if (!row) return null; - const record = toOAuthStateRecord(row); - return new Date(record.expiresAt) > new Date() ? record : null; - } - }, - oauthPendingLogins: { - async create(input) { - const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; - const createdAt = nowIso(); - await db - .prepare( - "INSERT INTO oauth_pending_logins (id, provider, provider_user_id, provider_email, provider_name, provider_login, redirect_to, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - id, - input.provider, - input.providerUserId, - input.providerEmail, - input.providerName, - input.providerLogin, - input.redirectTo, - input.expiresAt, - createdAt - ) - .run(); - return { id, ...input, createdAt }; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM oauth_pending_logins WHERE id = ?").bind(id).first(); - if (!row) return null; - const record = toOAuthPendingLoginRecord(row); - if (new Date(record.expiresAt) > new Date()) return record; - await db.prepare("DELETE FROM oauth_pending_logins WHERE id = ?").bind(id).run(); - return null; - }, - async consume(id) { - const row = await db.prepare("SELECT * FROM oauth_pending_logins WHERE id = ?").bind(id).first(); - await db.prepare("DELETE FROM oauth_pending_logins WHERE id = ?").bind(id).run(); - if (!row) return null; - const record = toOAuthPendingLoginRecord(row); - return new Date(record.expiresAt) > new Date() ? record : null; - } - }, - oauthIdentities: { - async findByProviderUser(provider, providerUserId) { - const row = await db - .prepare("SELECT * FROM oauth_identities WHERE provider = ? AND provider_user_id = ?") - .bind(provider, providerUserId) - .first(); - return row ? toOAuthIdentityRecord(row) : null; - }, - async upsert(input) { - const existing = await this.findByProviderUser(input.provider, input.providerUserId); - const updatedAt = nowIso(); - if (existing) { - await db - .prepare( - "UPDATE oauth_identities SET user_id = ?, provider_email = ?, provider_login = ?, updated_at = ? WHERE id = ?" - ) - .bind(input.userId, input.providerEmail, input.providerLogin, updatedAt, existing.id) - .run(); - return { ...existing, userId: input.userId, providerEmail: input.providerEmail, providerLogin: input.providerLogin, updatedAt }; - } - - const id = crypto.randomUUID(); - await db - .prepare( - "INSERT INTO oauth_identities (id, user_id, provider, provider_user_id, provider_email, provider_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind(id, input.userId, input.provider, input.providerUserId, input.providerEmail, input.providerLogin, updatedAt, updatedAt) - .run(); - return { id, ...input, createdAt: updatedAt, updatedAt }; - }, - async deleteByUserId(userId) { - await db.prepare("DELETE FROM oauth_identities WHERE user_id = ?").bind(userId).run(); - } - }, - invites: { - async create(input) { - const id = crypto.randomUUID(); - const createdAt = nowIso(); - const targetRole = input.targetRole ?? "member"; - const maxRedemptions = input.maxRedemptions ?? 1; - await db - .prepare( - "INSERT INTO user_invites (id, code, created_by_user_id, redeemed_by_user_id, redeemed_at, disabled_at, expires_at, target_role, max_redemptions, redemption_count, created_at) VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, ?, 0, ?)" - ) - .bind(id, input.code, input.createdByUserId, input.expiresAt ?? null, targetRole, maxRedemptions, createdAt) - .run(); - return { - id, - code: input.code, - createdByUserId: input.createdByUserId, - redeemedByUserId: null, - redeemedAt: null, - disabledAt: null, - expiresAt: input.expiresAt ?? null, - targetRole, - maxRedemptions, - redemptionCount: 0, - createdAt - }; - }, - async findByCode(code) { - const row = await db.prepare("SELECT * FROM user_invites WHERE code = ?").bind(code).first(); - return row ? toInviteRecord(row) : null; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM user_invites WHERE id = ?").bind(id).first(); - return row ? toInviteRecord(row) : null; - }, - async redeem(code, userId) { - const redeemedAt = nowIso(); - await db - .prepare( - "UPDATE user_invites SET redeemed_by_user_id = ?, redeemed_at = ?, redemption_count = redemption_count + 1 WHERE code = ? AND redemption_count < max_redemptions" - ) - .bind(userId, redeemedAt, code) - .run(); - return (await this.findByCode(code))!; - }, - async list() { - const result = await db.prepare("SELECT * FROM user_invites ORDER BY created_at DESC").all(); - return (result.results ?? []).map(toInviteRecord); - }, - async listPage(options) { - const totalRow = await db.prepare("SELECT count(*) AS count FROM user_invites").first<{ count: number }>(); - const availableRow = await db - .prepare( - "SELECT count(*) AS count FROM user_invites WHERE redemption_count < max_redemptions AND disabled_at IS NULL AND (expires_at IS NULL OR expires_at > ?)" - ) - .bind(nowIso()) - .first<{ count: number }>(); - const result = await db - .prepare( - `SELECT user_invites.*, users.name AS redeemed_by_user_name, users.email AS redeemed_by_user_email - FROM user_invites - LEFT JOIN users ON users.id = user_invites.redeemed_by_user_id - ORDER BY user_invites.created_at DESC - LIMIT ? OFFSET ?` - ) - .bind(options.pageSize, (options.page - 1) * options.pageSize) - .all(); - - return { - available: availableRow?.count ?? 0, - invites: (result.results ?? []).map(toInviteRecord), - page: options.page, - pageSize: options.pageSize, - total: totalRow?.count ?? 0 - }; - }, - async disable(id) { - await db.prepare("UPDATE user_invites SET disabled_at = ? WHERE id = ?").bind(nowIso(), id).run(); - } - }, - mailboxes: { - async countByUser(userId) { - const result = await db - .prepare("SELECT count(*) AS count FROM accounts WHERE user_id = ? AND status != 'soft_deleted'") - .bind(userId) - .first<{ count: number }>(); - return result?.count ?? 0; - }, - async create(input) { - const id = crypto.randomUUID(); - const createdAt = nowIso(); - await db - .prepare( - "INSERT INTO accounts (id, user_id, address, label, status, tags_json, created_by_user_id, last_active_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - id, - input.userId, - input.address, - input.label, - input.status ?? "enabled", - JSON.stringify(input.tags ?? []), - input.userId, - input.lastActiveAt ?? null, - createdAt - ) - .run(); - return { id, userId: input.userId, address: input.address, label: input.label, createdAt }; - }, - async update(id, input) { - const assignments: string[] = []; - const bindings: unknown[] = []; - - if (typeof input.label !== "undefined") { - assignments.push("label = ?"); - bindings.push(input.label); - } - - if (typeof input.status !== "undefined") { - assignments.push("status = ?"); - bindings.push(input.status); - assignments.push("deleted_at = ?"); - bindings.push(input.status === "soft_deleted" ? nowIso() : null); - } - - if (typeof input.deletedAt !== "undefined") { - assignments.push("deleted_at = ?"); - bindings.push(input.deletedAt); - } - - if (typeof input.lastActiveAt !== "undefined") { - assignments.push("last_active_at = ?"); - bindings.push(input.lastActiveAt); - } - - if (typeof input.tags !== "undefined") { - assignments.push("tags_json = ?"); - bindings.push(JSON.stringify(input.tags)); - } - - if (assignments.length === 0) return findMailboxDetailById(id); - - await db - .prepare(`UPDATE accounts SET ${assignments.join(", ")} WHERE id = ?`) - .bind(...bindings, id) - .run(); - return findMailboxDetailById(id); - }, - async listByUser(userId) { - const result = await db.prepare("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC").bind(userId).all(); - return (result.results ?? []).map(toMailboxRecord); - }, - async listAllWithDetails(query: MailboxDetailListQuery) { - const page = getSafePage(query.page); - const pageSize = getSafePageSize(query.pageSize); - const offset = (page - 1) * pageSize; - const whereConditions: string[] = []; - const bindings: any[] = []; - - if (query.userId) { - whereConditions.push("a.user_id = ?"); - bindings.push(query.userId); - } - - if (query.search) { - whereConditions.push("(a.id LIKE ? OR a.label LIKE ? OR a.address LIKE ? OR u.name LIKE ?)"); - const searchPattern = `%${query.search}%`; - bindings.push(searchPattern, searchPattern, searchPattern, searchPattern); - } - - if (query.status && query.status !== "all") { - whereConditions.push("a.status = ?"); - bindings.push(query.status); - } - - if (query.createdBy && query.createdBy !== "all") { - whereConditions.push("u.name = ?"); - bindings.push(query.createdBy); - } - - const activeRangeDays = getActiveRangeDays(query.activeRange); - if (activeRangeDays) { - whereConditions.push("a.last_active_at IS NOT NULL AND a.last_active_at >= ?"); - bindings.push(new Date(Date.now() - activeRangeDays * DAY_MS).toISOString()); - } - - if (query.quickFilter === "anomaly") { - whereConditions.push("a.status <> 'enabled'"); - } - - if (query.quickFilter === "inactive") { - whereConditions.push("(a.last_active_at IS NULL OR a.last_active_at < ?)"); - bindings.push(new Date(Date.now() - getInactiveDays(query.inactiveDays) * DAY_MS).toISOString()); - } - - const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; - - const countQuery = ` - SELECT COUNT(*) as count - FROM accounts a - LEFT JOIN users u ON a.created_by_user_id = u.id - ${whereClause} - `; - const countResult = await db.prepare(countQuery).bind(...bindings).first<{ count: number }>(); - const total = countResult?.count ?? 0; - - const dataQuery = ` - SELECT - a.id, - a.user_id, - a.address, - a.label, - a.status, - a.tags_json, - a.created_by_user_id, - a.last_active_at, - a.deleted_at, - a.created_at, - u.name as created_by_name, - (SELECT COUNT(*) FROM mail_messages WHERE account_id = a.id) as message_count, - (SELECT COUNT(*) FROM mail_outbound_messages WHERE account_id = a.id) as outbound_count - FROM accounts a - LEFT JOIN users u ON a.created_by_user_id = u.id - ${whereClause} - ORDER BY a.created_at DESC - LIMIT ? OFFSET ? - `; - const dataResult = await db.prepare(dataQuery).bind(...bindings, pageSize, offset).all(); - - const accounts = (dataResult.results ?? []).map(toMailboxDetailRecord); - - return { accounts, total }; - }, - async listAll() { - const result = await db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all(); - return (result.results ?? []).map(toMailboxRecord); - }, - async listPage(options) { - const totalRow = await db.prepare("SELECT count(*) AS count FROM accounts").first<{ count: number }>(); - const latestRow = await db.prepare("SELECT * FROM accounts ORDER BY created_at DESC LIMIT 1").first(); - const result = await db - .prepare("SELECT * FROM accounts ORDER BY created_at DESC LIMIT ? OFFSET ?") - .bind(options.pageSize, (options.page - 1) * options.pageSize) - .all(); - - return { - latestMailbox: latestRow ? toMailboxRecord(latestRow) : null, - mailboxes: (result.results ?? []).map(toMailboxRecord), - page: options.page, - pageSize: options.pageSize, - total: totalRow?.count ?? 0 - }; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first(); - return row ? toMailboxRecord(row) : null; - }, - async findDetailById(id) { - return findMailboxDetailById(id); - }, - async findByAddress(address) { - const row = await db - .prepare("SELECT * FROM accounts WHERE address = ? AND status <> 'soft_deleted'") - .bind(address) - .first(); - return row ? toMailboxRecord(row) : null; - }, - async delete(id) { - await db.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run(); - } - }, - messages: { - async create(input) { - const id = crypto.randomUUID(); - await db - .prepare( - "INSERT INTO mail_messages (id, account_id, to_address, from_address, subject, preview_text, body_text, extraction_json, oversize_status, attachment_count, received_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - id, - input.mailboxId, - input.toAddress ?? null, - input.fromAddress, - input.subject, - input.previewText, - input.bodyText, - input.extractionJson, - input.oversizeStatus, - input.attachmentCount, - input.receivedAt, - input.expiresAt - ) - .run(); - return { id, ...input }; - }, - async listForMailboxes(query) { - const mailboxIds = Array.from(new Set(query.mailboxIds)).filter(Boolean); - const page = getSafePage(query.page); - const pageSize = getSafePageSize(query.pageSize); - - if (mailboxIds.length === 0 && !query.includeUnmatched) { - return { - messages: [], - page, - pageSize, - summary: { messageCount: 0, extractionCount: 0, attachmentCount: 0 }, - total: 0 - }; - } - - const bindings: unknown[] = []; - const scopeConditions: string[] = []; - - if (mailboxIds.length > 0) { - // D1 limits bound SQL variables; json_each keeps large mailbox scopes to one binding. - scopeConditions.push("account_id IN (SELECT value FROM json_each(?))"); - bindings.push(JSON.stringify(mailboxIds)); - } - if (query.includeUnmatched) { - scopeConditions.push("account_id LIKE 'unmatched:%'"); - } - - const whereConditions = [`(${scopeConditions.join(" OR ")})`]; - const search = query.search?.trim(); - - if (search) { - const searchLike = `%${search}%`; - whereConditions.push( - "(to_address LIKE ? COLLATE NOCASE OR from_address LIKE ? COLLATE NOCASE OR subject LIKE ? COLLATE NOCASE OR preview_text LIKE ? COLLATE NOCASE OR body_text LIKE ? COLLATE NOCASE OR extraction_json LIKE ? COLLATE NOCASE)" - ); - bindings.push(searchLike, searchLike, searchLike, searchLike, searchLike, searchLike); - } - - if (query.filter === "code") { - whereConditions.push("json_extract(extraction_json, '$.type') = 'auth_code'"); - } - if (query.filter === "link") { - whereConditions.push("json_extract(extraction_json, '$.type') NOT IN ('auth_code', 'none')"); - } - if (query.filter === "attachment") { - whereConditions.push("attachment_count > 0"); - } - if (query.filter === "unparsed") { - whereConditions.push("json_extract(extraction_json, '$.type') = 'none'"); - } - - const from = query.from?.trim(); - if (from) { - whereConditions.push("from_address LIKE ? COLLATE NOCASE"); - bindings.push(`%${from}%`); - } - - const subject = query.subject?.trim(); - if (subject) { - whereConditions.push("subject LIKE ? COLLATE NOCASE"); - bindings.push(`%${subject}%`); - } - - if (query.startDate) { - whereConditions.push("received_at >= ?"); - bindings.push(query.startDate); - } - - if (query.endDate) { - whereConditions.push("received_at <= ?"); - bindings.push(query.endDate); - } - - if (typeof query.hasAttachment === "boolean") { - whereConditions.push(query.hasAttachment ? "attachment_count > 0" : "attachment_count = 0"); - } - - if (query.extractionType) { - whereConditions.push("json_extract(extraction_json, '$.type') = ?"); - bindings.push(query.extractionType); - } - - const whereClause = `WHERE ${whereConditions.join(" AND ")}`; - const totalRow = await db - .prepare(`SELECT COUNT(*) AS count FROM mail_messages ${whereClause}`) - .bind(...bindings) - .first<{ count: number }>(); - const summaryRow = await db - .prepare( - `SELECT - COUNT(*) AS message_count, - COALESCE(SUM(CASE WHEN json_extract(extraction_json, '$.type') <> 'none' AND trim(COALESCE(json_extract(extraction_json, '$.value'), '')) <> '' THEN 1 ELSE 0 END), 0) AS extraction_count, - COALESCE(SUM(attachment_count), 0) AS attachment_count - FROM mail_messages ${whereClause}` - ) - .bind(...bindings) - .first<{ message_count: number; extraction_count: number; attachment_count: number }>(); - const result = await db - .prepare(`SELECT * FROM mail_messages ${whereClause} ORDER BY received_at DESC LIMIT ? OFFSET ?`) - .bind(...bindings, pageSize, (page - 1) * pageSize) - .all(); - - return { - messages: (result.results ?? []).map(toMessageRecord), - page, - pageSize, - summary: { - messageCount: Number(summaryRow?.message_count ?? 0), - extractionCount: Number(summaryRow?.extraction_count ?? 0), - attachmentCount: Number(summaryRow?.attachment_count ?? 0) - }, - total: Number(totalRow?.count ?? 0) - }; - }, - async listByMailbox(mailboxId) { - const result = await db - .prepare("SELECT * FROM mail_messages WHERE account_id = ? ORDER BY received_at DESC") - .bind(mailboxId) - .all(); - return (result.results ?? []).map(toMessageRecord); - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM mail_messages WHERE id = ?").bind(id).first(); - return row ? toMessageRecord(row) : null; - }, - async listExpired(beforeIso) { - const result = await db.prepare("SELECT * FROM mail_messages WHERE expires_at <= ?").bind(beforeIso).all(); - return (result.results ?? []).map(toMessageRecord); - }, - async deleteMany(ids) { - if (ids.length === 0) return; - const placeholders = ids.map(() => "?").join(", "); - await db.prepare(`DELETE FROM mail_messages WHERE id IN (${placeholders})`).bind(...ids).run(); - } - }, - attachments: { - async createMany(messageId, nextAttachments) { - for (const attachment of nextAttachments) { - await db - .prepare( - "INSERT INTO mail_attachments (id, message_id, filename, content_type, size, storage_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - attachment.id, - messageId, - attachment.filename, - attachment.contentType, - attachment.size, - attachment.key, - nowIso() - ) - .run(); - } - }, - async listByMessage(messageId) { - const result = await db - .prepare("SELECT id, filename, content_type, size, storage_key FROM mail_attachments WHERE message_id = ?") - .bind(messageId) - .all(); - return (result.results ?? []).map((row: any) => ({ - id: row.id, - filename: row.filename, - contentType: row.content_type, - size: Number(row.size), - key: row.storage_key - })) as AttachmentRecord[]; - }, - async listByMessageIds(messageIds) { - if (messageIds.length === 0) return []; - const placeholders = messageIds.map(() => "?").join(", "); - const result = await db - .prepare(`SELECT id, filename, content_type, size, storage_key FROM mail_attachments WHERE message_id IN (${placeholders})`) - .bind(...messageIds) - .all(); - return (result.results ?? []).map((row: any) => ({ - id: row.id, - filename: row.filename, - contentType: row.content_type, - size: Number(row.size), - key: row.storage_key - })) as AttachmentRecord[]; - }, - async deleteByMessageIds(messageIds) { - if (messageIds.length === 0) return; - const placeholders = messageIds.map(() => "?").join(", "); - await db.prepare(`DELETE FROM mail_attachments WHERE message_id IN (${placeholders})`).bind(...messageIds).run(); - } - }, - outboundMessages: { - async create(input) { - const id = crypto.randomUUID(); - const createdAt = nowIso(); - await db - .prepare( - "INSERT INTO mail_outbound_messages (id, account_id, from_address, to_address, subject, body_text, status, error_text, provider_message_id, request_payload_json, response_payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - id, - input.mailboxId, - input.fromAddress, - input.toAddress, - input.subject, - input.bodyText, - input.status, - input.errorText, - input.providerMessageId, - input.requestPayloadJson, - input.responsePayloadJson, - createdAt - ) - .run(); - return { - id, - createdAt, - ...input - }; - }, - async listByMailbox(query) { - const page = getSafePage(query.page); - const pageSize = getSafePageSize(query.pageSize); - const baseBindings: unknown[] = [query.mailboxId]; - const baseConditions = ["account_id = ?"]; - const search = query.search?.trim(); - - if (search) { - const searchLike = `%${search}%`; - baseConditions.push( - "(from_address LIKE ? COLLATE NOCASE OR to_address LIKE ? COLLATE NOCASE OR subject LIKE ? COLLATE NOCASE OR body_text LIKE ? COLLATE NOCASE OR error_text LIKE ? COLLATE NOCASE OR provider_message_id LIKE ? COLLATE NOCASE OR request_payload_json LIKE ? COLLATE NOCASE OR response_payload_json LIKE ? COLLATE NOCASE)" - ); - baseBindings.push(searchLike, searchLike, searchLike, searchLike, searchLike, searchLike, searchLike, searchLike); - } - - const filteredConditions = [...baseConditions]; - const filteredBindings = [...baseBindings]; - if (query.status === "sent" || query.status === "failed") { - filteredConditions.push("status = ?"); - filteredBindings.push(query.status); - } - - const baseWhereClause = `WHERE ${baseConditions.join(" AND ")}`; - const filteredWhereClause = `WHERE ${filteredConditions.join(" AND ")}`; - const totalRow = await db - .prepare(`SELECT COUNT(*) AS count FROM mail_outbound_messages ${filteredWhereClause}`) - .bind(...filteredBindings) - .first<{ count: number }>(); - const summaryRow = await db - .prepare( - `SELECT - COUNT(*) AS total_count, - COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0) AS sent_count, - COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count - FROM mail_outbound_messages ${baseWhereClause}` - ) - .bind(...baseBindings) - .first<{ total_count: number; sent_count: number; failed_count: number }>(); - const result = await db - .prepare(`SELECT * FROM mail_outbound_messages ${filteredWhereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`) - .bind(...filteredBindings, pageSize, (page - 1) * pageSize) - .all(); - - return { - messages: (result.results ?? []).map(toOutboundMessageRecord), - page, - pageSize, - summary: { - totalCount: Number(summaryRow?.total_count ?? 0), - sentCount: Number(summaryRow?.sent_count ?? 0), - failedCount: Number(summaryRow?.failed_count ?? 0) - }, - total: Number(totalRow?.count ?? 0) - }; - }, - async summarizeByMailboxes(query: OutboundMessageUsageQuery): Promise { - const mailboxIds = Array.from(new Set(query.mailboxIds)).filter(Boolean); - if (mailboxIds.length === 0) { - return { - totalCount: 0, - sentCount: 0, - failedCount: 0, - sentSinceCount: 0 - }; - } - - const sentSinceExpression = query.sinceIso - ? "COALESCE(SUM(CASE WHEN status = 'sent' AND created_at >= ? THEN 1 ELSE 0 END), 0)" - : "COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0)"; - const bindings: unknown[] = query.sinceIso - ? [query.sinceIso, JSON.stringify(mailboxIds)] - : [JSON.stringify(mailboxIds)]; - // Admin summaries only need counts; materializing outbound bodies makes this request grow with mailbox history. - const row = await db - .prepare( - `SELECT - COUNT(*) AS total_count, - COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0) AS sent_count, - COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count, - ${sentSinceExpression} AS sent_since_count - FROM mail_outbound_messages - WHERE account_id IN (SELECT value FROM json_each(?))` - ) - .bind(...bindings) - .first<{ total_count: number; sent_count: number; failed_count: number; sent_since_count: number }>(); - - return { - totalCount: Number(row?.total_count ?? 0), - sentCount: Number(row?.sent_count ?? 0), - failedCount: Number(row?.failed_count ?? 0), - sentSinceCount: Number(row?.sent_since_count ?? 0) - }; - }, - async findById(id) { - const row = await db.prepare("SELECT * FROM mail_outbound_messages WHERE id = ?").bind(id).first(); - return row ? toOutboundMessageRecord(row) : null; - } - }, - apiKeys: { - async create(input) { - const record = { - id: crypto.randomUUID(), - userId: input.userId, - label: input.label, - prefix: input.prefix, - scopes: input.scopes, - keyHash: input.keyHash, - createdAt: nowIso(), - lastUsedAt: null, - revokedAt: null - }; - await db - .prepare( - "INSERT INTO api_keys (id, user_id, label, prefix, scopes_json, key_hash, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)" - ) - .bind(record.id, record.userId, record.label, record.prefix, JSON.stringify(record.scopes), record.keyHash, record.createdAt) - .run(); - return record; - }, - async listByUser(userId) { - const result = await db.prepare("SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at DESC").bind(userId).all(); - return (result.results ?? []).map(toApiKeyRecord); - }, - async listAll() { - const result = await db.prepare("SELECT * FROM api_keys ORDER BY created_at DESC, id DESC").all(); - return (result.results ?? []).map(toApiKeyRecord); - }, - async findActiveByHash(hash) { - const row = await db - .prepare("SELECT * FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL") - .bind(hash) - .first(); - return row ? toApiKeyRecord(row) : null; - }, - async touch(id) { - await db.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?").bind(nowIso(), id).run(); - }, - async revoke(id, userId) { - await db.prepare("UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?").bind(nowIso(), id, userId).run(); - } - }, - telegram: { - async upsert(input) { - const existing = await db - .prepare("SELECT * FROM telegram_subscriptions WHERE user_id = ?") - .bind(input.userId) - .first(); - const createdAt = existing?.created_at ?? nowIso(); - const id = existing?.id ?? crypto.randomUUID(); - const updatedAt = nowIso(); - await db - .prepare("DELETE FROM telegram_subscriptions WHERE chat_id = ? AND user_id <> ?") - .bind(input.chatId, input.userId) - .run(); - await db - .prepare( - "INSERT OR REPLACE INTO telegram_subscriptions (id, user_id, chat_id, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(id, input.userId, input.chatId, input.enabled ? 1 : 0, createdAt, updatedAt) - .run(); - return { id, userId: input.userId, chatId: input.chatId, enabled: input.enabled, createdAt, updatedAt }; - }, - async findByUserId(userId) { - const row = await db - .prepare("SELECT * FROM telegram_subscriptions WHERE user_id = ?") - .bind(userId) - .first(); - return row - ? { - id: row.id, - userId: row.user_id, - chatId: row.chat_id, - enabled: toBool(row.enabled), - createdAt: row.created_at, - updatedAt: row.updated_at - } - : null; - }, - async findByChatId(chatId) { - const row = await db - .prepare("SELECT * FROM telegram_subscriptions WHERE chat_id = ? ORDER BY updated_at DESC LIMIT 1") - .bind(chatId) - .first(); - return row - ? { - id: row.id, - userId: row.user_id, - chatId: row.chat_id, - enabled: toBool(row.enabled), - createdAt: row.created_at, - updatedAt: row.updated_at - } - : null; - } - }, - quotas: { - async getByUserId(userId, fallbackLimit, fallbackApiDailyLimit) { - const row = await db.prepare("SELECT * FROM user_send_quotas WHERE user_id = ?").bind(userId).first(); - if (!row) { - const next: QuotaRecord = { - userId, - apiDailyLimit: fallbackApiDailyLimit, - apiCallsToday: 0, - dailyLimit: fallbackLimit, - sendsToday: 0, - disabled: false, - updatedAt: nowIso() - }; - await db - .prepare( - "INSERT INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" - ) - .bind(userId, next.dailyLimit, next.sendsToday, next.apiDailyLimit, next.apiCallsToday, 0, next.updatedAt) - .run(); - return next; - } - return { - userId: row.user_id, - apiDailyLimit: Number(row.api_daily_limit ?? fallbackApiDailyLimit), - apiCallsToday: Number(row.api_calls_today ?? 0), - dailyLimit: Number(row.daily_limit), - sendsToday: Number(row.sends_today), - disabled: toBool(row.disabled), - updatedAt: row.updated_at - }; - }, - async consumeApiCall(userId, fallbackLimit, fallbackApiDailyLimit) { - const now = nowIso(); - const today = now.slice(0, 10); - await db - .prepare( - "INSERT OR IGNORE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, 0, ?, 0, 0, ?)" - ) - .bind(userId, fallbackLimit, fallbackApiDailyLimit, now) - .run(); - - const result = await db - .prepare( - ` - UPDATE user_send_quotas - SET - sends_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE sends_today END, - api_calls_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 1 ELSE api_calls_today + 1 END, - updated_at = ? - WHERE user_id = ? - AND (CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE api_calls_today END) < api_daily_limit - ` - ) - .bind(today, today, now, userId, today) - .run(); - - if (Number(result.meta.changes ?? 0) === 0) return null; - return this.getByUserId(userId, fallbackLimit, fallbackApiDailyLimit); - }, - async consumeOutboundSend(userId, fallbackLimit, fallbackApiDailyLimit) { - const now = nowIso(); - const today = now.slice(0, 10); - await db - .prepare( - "INSERT OR IGNORE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, 0, ?, 0, 0, ?)" - ) - .bind(userId, fallbackLimit, fallbackApiDailyLimit, now) - .run(); - - const result = await db - .prepare( - ` - UPDATE user_send_quotas - SET - sends_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 1 ELSE sends_today + 1 END, - api_calls_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE api_calls_today END, - updated_at = ? - WHERE user_id = ? - AND disabled = 0 - AND (CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE sends_today END) < daily_limit - ` - ) - .bind(today, today, now, userId, today) - .run(); - - if (Number(result.meta.changes ?? 0) === 0) return null; - return this.getByUserId(userId, fallbackLimit, fallbackApiDailyLimit); - }, - async save(quota) { - await db - .prepare( - "INSERT OR REPLACE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - quota.userId, - quota.dailyLimit, - quota.sendsToday, - quota.apiDailyLimit, - quota.apiCallsToday, - quota.disabled ? 1 : 0, - quota.updatedAt - ) - .run(); - } - }, - settings: { - async getFeatureToggles(defaults) { - const result = await db - .prepare("SELECT key, value FROM system_settings WHERE key IN (?, ?, ?, ?)") - .bind("aiEnabled", "telegramEnabled", "outboundEnabled", "mailboxCreationEnabled") - .all(); - const map = new Map((result.results ?? []).map((row: any) => [row.key, parseJson(row.value, false)])); - return { - aiEnabled: map.get("aiEnabled") ?? defaults.aiEnabled, - telegramEnabled: map.get("telegramEnabled") ?? defaults.telegramEnabled, - outboundEnabled: map.get("outboundEnabled") ?? defaults.outboundEnabled, - mailboxCreationEnabled: map.get("mailboxCreationEnabled") ?? defaults.mailboxCreationEnabled - }; - }, - async saveFeatureToggles(toggles) { - for (const [key, value] of Object.entries(toggles)) { - await db - .prepare("INSERT OR REPLACE INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)") - .bind(key, JSON.stringify(value), nowIso()) - .run(); - } - return toggles; - } - }, - runtimeSettings: { - async get() { - const result = await db - .prepare( - "SELECT key, value, updated_at FROM system_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - "runtime.mailbox.limit", - "runtime.message.retentionDays", - "runtime.outbound.dailyLimit", - "runtime.api.dailyLimit", - "runtime.attachments.maxBytes", - "runtime.attachments.maxTotalBytes", - "runtime.ai.fallbackLimit" - ) - .all(); - - const rows = result.results ?? []; - if (rows.length === 0) return null; - const map = new Map(rows.map((row: any) => [row.key, row.value as string])); - const updatedAt = rows - .map((row: any) => row.updated_at as string | null | undefined) - .filter((value): value is string => Boolean(value)) - .sort() - .at(-1) ?? nowIso(); - - return { - mailboxLimit: map.get("runtime.mailbox.limit") ?? "", - messageRetentionDays: map.get("runtime.message.retentionDays") ?? "", - outboundDailyLimit: map.get("runtime.outbound.dailyLimit") ?? "", - apiDailyLimit: map.get("runtime.api.dailyLimit") ?? "", - maxAttachmentBytes: map.get("runtime.attachments.maxBytes") ?? "", - maxTotalAttachmentBytes: map.get("runtime.attachments.maxTotalBytes") ?? "", - aiFallbackLimit: map.get("runtime.ai.fallbackLimit") ?? "", - updatedAt - } satisfies RuntimeSettingsRecord; - }, - async save(record) { - const updatedAt = nowIso(); - const entries = [ - ["runtime.mailbox.limit", record.mailboxLimit], - ["runtime.message.retentionDays", record.messageRetentionDays], - ["runtime.outbound.dailyLimit", record.outboundDailyLimit], - ["runtime.api.dailyLimit", record.apiDailyLimit], - ["runtime.attachments.maxBytes", record.maxAttachmentBytes], - ["runtime.attachments.maxTotalBytes", record.maxTotalAttachmentBytes], - ["runtime.ai.fallbackLimit", record.aiFallbackLimit] - ] as const; - - for (const [key, value] of entries) { - await db - .prepare("INSERT OR REPLACE INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)") - .bind(key, value, updatedAt) - .run(); - } - - return { - ...record, - updatedAt - }; - } - }, - mailDomains: { - async list(defaults) { - const result = await db - .prepare("SELECT domain, allowed_roles_json FROM mail_domains ORDER BY sort_order ASC, created_at ASC") - .all(); - const rows = result.results ?? []; - if (rows.length === 0) return defaults; - return rows.map((row: any): MailDomainSummary => ({ - domain: row.domain, - allowedRoles: parseAllowedRoles(row.allowed_roles_json) - })); - }, - async saveAll(domains) { - await db.prepare("DELETE FROM mail_domains").run(); - const now = nowIso(); - for (const [index, domain] of domains.entries()) { - await db - .prepare( - "INSERT INTO mail_domains (id, domain, allowed_roles_json, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(crypto.randomUUID(), domain.domain, JSON.stringify(domain.allowedRoles), index, now, now) - .run(); - } - return domains; - } - }, - dictionaries: { - async listGroups(groupKeys, options) { - const [groupResult, itemResult] = await Promise.all([ - db.prepare("SELECT * FROM dictionary_groups").all(), - db.prepare("SELECT * FROM dictionary_items").all() - ]); - - return buildDictionaryCatalog({ - groupKeys, - groups: (groupResult.results ?? []).map(toDictionaryGroup), - includeDisabled: options?.includeDisabled, - items: (itemResult.results ?? []).map(toDictionaryItem) - }); - }, - async updateItem(groupKey, value, input) { - const existingRow = await db - .prepare("SELECT * FROM dictionary_items WHERE group_key = ? AND value = ?") - .bind(groupKey, value) - .first(); - const existing = existingRow ? toDictionaryItem(existingRow) : findDefaultDictionaryItem(groupKey, value); - if (!existing) return null; - - const group = findDefaultDictionaryGroup(groupKey); - if (group) { - await db - .prepare( - "INSERT OR IGNORE INTO dictionary_groups (group_key, label, description, is_system, version, updated_at) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(group.groupKey, group.label, group.description, group.isSystem ? 1 : 0, group.version, nowIso()) - .run(); - } - - const next = applyDictionaryItemUpdate(existing, input); - const updatedAt = nowIso(); - await db - .prepare( - "INSERT OR REPLACE INTO dictionary_items (id, group_key, value, label, description, sort_order, enabled, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - existingRow?.id ?? crypto.randomUUID(), - groupKey, - value, - next.label, - next.description, - next.sortOrder, - next.enabled ? 1 : 0, - JSON.stringify(next.metadata), - updatedAt - ) - .run(); - return { ...next, updatedAt }; - } - }, - audit: { - async record(event) { - await db - .prepare( - "INSERT INTO system_audit_events (id, actor_type, actor_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)" - ) - .bind(crypto.randomUUID(), event.actorType, event.actorId, event.eventType, event.payloadJson, nowIso()) - .run(); - }, - async countByActorSince(actorId, eventType, sinceIso) { - const result = await db - .prepare( - "SELECT count(*) AS count FROM system_audit_events WHERE actor_id = ? AND event_type = ? AND created_at >= ?" - ) - .bind(actorId, eventType, sinceIso) - .first<{ count: number }>(); - return result?.count ?? 0; - }, - async listByActorAndTypes(actorId, eventTypes, limit) { - if (eventTypes.length === 0) return []; - const placeholders = eventTypes.map(() => "?").join(", "); - const result = await db - .prepare( - `SELECT * FROM system_audit_events WHERE actor_id = ? AND event_type IN (${placeholders}) ORDER BY created_at DESC LIMIT ?` - ) - .bind(actorId, ...eventTypes, limit) - .all(); - return (result.results ?? []).map((row) => ({ - id: row.id, - actorType: row.actor_type, - actorId: row.actor_id, - eventType: row.event_type, - payloadJson: row.payload_json, - createdAt: row.created_at - })); - }, - async listRecent(options) { - const eventTypes = options?.eventTypes ?? []; - const limit = Math.min(Math.max(Math.trunc(options?.limit ?? 30), 1), 500); - const whereClause = eventTypes.length > 0 ? `WHERE event_type IN (${eventTypes.map(() => "?").join(", ")})` : ""; - const result = await db - .prepare(`SELECT * FROM system_audit_events ${whereClause} ORDER BY created_at DESC LIMIT ?`) - .bind(...eventTypes, limit) - .all(); - return (result.results ?? []).map((row) => ({ - id: row.id, - actorType: row.actor_type, - actorId: row.actor_id, - eventType: row.event_type, - payloadJson: row.payload_json, - createdAt: row.created_at - })); - } - }, - cleanupRuns: { - async record(input) { - const id = crypto.randomUUID(); - await db - .prepare( - "INSERT INTO system_cleanup_runs (id, status, started_at, finished_at, deleted_messages, deleted_attachments, deleted_accounts, error_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - id, - input.status, - input.startedAt, - input.finishedAt, - input.deletedMessages, - input.deletedAttachments, - input.deletedAccounts, - input.errorText - ) - .run(); - return { id, ...input }; - }, - async listRecent(limit) { - const result = await db - .prepare("SELECT * FROM system_cleanup_runs ORDER BY started_at DESC LIMIT ?") - .bind(getSafePageSize(limit)) - .all(); - return (result.results ?? []).map((row) => ({ - id: row.id, - status: row.status === "failed" ? "failed" : "success", - startedAt: row.started_at, - finishedAt: row.finished_at, - deletedMessages: Number(row.deleted_messages ?? 0), - deletedAttachments: Number(row.deleted_attachments ?? 0), - deletedAccounts: Number(row.deleted_accounts ?? 0), - errorText: row.error_text ?? null - })); - } - }, - accountSettings: { - async get() { - const row = await db.prepare("SELECT * FROM account_settings WHERE id = ?").bind("account_settings").first(); - return row - ? { - id: row.id, - creationJson: row.creation_json, - lifecycleJson: row.lifecycle_json, - protectionJson: row.protection_json, - updatedAt: row.updated_at - } - : null; - }, - async save(record) { - const updatedAt = nowIso(); - await db - .prepare( - "INSERT OR REPLACE INTO account_settings (id, creation_json, lifecycle_json, protection_json, updated_at) VALUES (?, ?, ?, ?, ?)" - ) - .bind("account_settings", record.creationJson, record.lifecycleJson, record.protectionJson, updatedAt) - .run(); - return { id: "account_settings", updatedAt, ...record }; - } - }, - mailSettings: { - async get() { - const row = await db.prepare("SELECT * FROM mail_settings WHERE id = ?").bind("mail_settings").first(); - return row - ? { - id: row.id, - senderRulesJson: row.sender_rules_json, - routingJson: row.routing_json, - workspaceDefaultsJson: row.workspace_defaults_json, - updatedAt: row.updated_at - } - : null; - }, - async save(record) { - const updatedAt = nowIso(); - await db - .prepare( - "INSERT OR REPLACE INTO mail_settings (id, sender_rules_json, routing_json, workspace_defaults_json, updated_at) VALUES (?, ?, ?, ?, ?)" - ) - .bind("mail_settings", record.senderRulesJson, record.routingJson, record.workspaceDefaultsJson, updatedAt) - .run(); - return { id: "mail_settings", updatedAt, ...record }; - } - }, - webhookEndpoints: { - async listByUser(userId) { - const result = await db - .prepare("SELECT * FROM webhook_endpoints WHERE user_id = ? ORDER BY created_at DESC") - .bind(userId) - .all(); - return (result.results ?? []).map((row: any) => ({ - id: row.id, - userId: row.user_id, - name: row.name, - url: row.url, - eventsJson: row.events_json, - signingSecret: row.signing_secret, - enabled: toBool(row.enabled), - createdAt: row.created_at, - updatedAt: row.updated_at - })); - }, - async listByUserPage(userId, options) { - const totalRow = await db - .prepare("SELECT count(*) AS count FROM webhook_endpoints WHERE user_id = ?") - .bind(userId) - .first<{ count: number }>(); - const result = await db - .prepare("SELECT * FROM webhook_endpoints WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?") - .bind(userId, options.pageSize, (options.page - 1) * options.pageSize) - .all(); - return { - endpoints: (result.results ?? []).map((row: any) => ({ - id: row.id, - userId: row.user_id, - name: row.name, - url: row.url, - eventsJson: row.events_json, - signingSecret: row.signing_secret, - enabled: toBool(row.enabled), - createdAt: row.created_at, - updatedAt: row.updated_at - })), - total: Number(totalRow?.count ?? 0), - page: options.page, - pageSize: options.pageSize - }; - }, - async create(input) { - const now = nowIso(); - const record = { - id: crypto.randomUUID(), - userId: input.userId, - name: input.name, - url: input.url, - eventsJson: input.eventsJson, - signingSecret: crypto.randomUUID().replaceAll("-", ""), - enabled: input.enabled, - createdAt: now, - updatedAt: now - }; - await db - .prepare( - "INSERT INTO webhook_endpoints (id, user_id, name, url, events_json, signing_secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - record.id, - record.userId, - record.name, - record.url, - record.eventsJson, - record.signingSecret, - record.enabled ? 1 : 0, - record.createdAt, - record.updatedAt - ) - .run(); - return record; - }, - async update(id, userId, input) { - const updatedAt = nowIso(); - await db - .prepare( - "UPDATE webhook_endpoints SET name = ?, url = ?, events_json = ?, enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?" - ) - .bind(input.name, input.url, input.eventsJson, input.enabled ? 1 : 0, updatedAt, id, userId) - .run(); - return (await this.listByUser(userId)).find((endpoint) => endpoint.id === id) ?? null; - }, - async rotateSecret(id, userId) { - const updatedAt = nowIso(); - const signingSecret = crypto.randomUUID().replaceAll("-", ""); - await db - .prepare("UPDATE webhook_endpoints SET signing_secret = ?, updated_at = ? WHERE id = ? AND user_id = ?") - .bind(signingSecret, updatedAt, id, userId) - .run(); - return (await this.listByUser(userId)).find((endpoint) => endpoint.id === id) ?? null; - }, - async delete(id, userId) { - const existing = (await this.listByUser(userId)).find((endpoint) => endpoint.id === id); - if (!existing) return; - await db.prepare("DELETE FROM webhook_deliveries WHERE endpoint_id = ?").bind(id).run(); - await db.prepare("DELETE FROM webhook_endpoints WHERE id = ? AND user_id = ?").bind(id, userId).run(); - } - }, - webhookDeliveries: { - async listByUser(userId) { - const result = await db - .prepare( - "SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE e.user_id = ? ORDER BY d.created_at DESC" - ) - .bind(userId) - .all(); - return (result.results ?? []).map((row: any) => ({ - id: row.id, - endpointId: row.endpoint_id, - eventType: row.event_type, - status: row.status, - statusCode: row.status_code === null ? null : Number(row.status_code), - durationMs: row.duration_ms === null ? null : Number(row.duration_ms), - errorText: row.error_text, - payloadJson: row.payload_json, - responseText: row.response_text ?? null, - createdAt: row.created_at - })); - }, - async listByUserPage(userId, query) { - const filters = ["e.user_id = ?"]; - const bindings: Array = [userId]; - if (query.endpointId) { - filters.push("d.endpoint_id = ?"); - bindings.push(query.endpointId); - } - if (query.status && query.status !== "all") { - filters.push("d.status = ?"); - bindings.push(query.status); - } - const whereClause = filters.join(" AND "); - const totalRow = await db - .prepare(`SELECT count(*) AS count FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE ${whereClause}`) - .bind(...bindings) - .first<{ count: number }>(); - const result = await db - .prepare( - `SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE ${whereClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?` - ) - .bind(...bindings, query.pageSize, (query.page - 1) * query.pageSize) - .all(); - return { - deliveries: (result.results ?? []).map((row: any) => ({ - id: row.id, - endpointId: row.endpoint_id, - eventType: row.event_type, - status: row.status, - statusCode: row.status_code === null ? null : Number(row.status_code), - durationMs: row.duration_ms === null ? null : Number(row.duration_ms), - errorText: row.error_text, - payloadJson: row.payload_json, - responseText: row.response_text ?? null, - createdAt: row.created_at - })), - total: Number(totalRow?.count ?? 0), - page: query.page, - pageSize: query.pageSize - }; - }, - async findByUser(id, userId) { - const row = await db - .prepare( - "SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE d.id = ? AND e.user_id = ?" - ) - .bind(id, userId) - .first(); - if (!row) return null; - return { - id: row.id, - endpointId: row.endpoint_id, - eventType: row.event_type, - status: row.status, - statusCode: row.status_code === null ? null : Number(row.status_code), - durationMs: row.duration_ms === null ? null : Number(row.duration_ms), - errorText: row.error_text, - payloadJson: row.payload_json, - responseText: row.response_text ?? null, - createdAt: row.created_at - }; - }, - async record(input) { - const { createdAt, id, ...recordInput } = input; - const record = { id: id ?? crypto.randomUUID(), createdAt: createdAt ?? nowIso(), ...recordInput }; - await db - .prepare( - "INSERT INTO webhook_deliveries (id, endpoint_id, event_type, status, status_code, duration_ms, error_text, payload_json, response_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - record.id, - record.endpointId, - record.eventType, - record.status, - record.statusCode, - record.durationMs, - record.errorText, - record.payloadJson, - record.responseText, - record.createdAt - ) - .run(); - return record; - } - }, - notificationRules: { - async listByUser(userId) { - const result = await db - .prepare("SELECT * FROM notification_rules WHERE user_id = ? ORDER BY updated_at DESC") - .bind(userId) - .all(); - return (result.results ?? []).map(toNotificationRuleRecord); - }, - async create(input) { - const record: NotificationRuleRecord = { - id: crypto.randomUUID(), - createdAt: nowIso(), - updatedAt: nowIso(), - ...input - }; - await db - .prepare( - "INSERT INTO notification_rules (id, user_id, name, enabled, target, target_id, event_types_json, mailbox_ids_json, keyword, quiet_hours_start, quiet_hours_end, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - record.id, - record.userId, - record.name, - record.enabled ? 1 : 0, - record.target, - record.targetId, - record.eventTypesJson, - record.mailboxIdsJson, - record.keyword, - record.quietHoursStart, - record.quietHoursEnd, - record.createdAt, - record.updatedAt - ) - .run(); - return record; - }, - async update(id, userId, input) { - const updatedAt = nowIso(); - await db - .prepare( - "UPDATE notification_rules SET name = ?, enabled = ?, target = ?, target_id = ?, event_types_json = ?, mailbox_ids_json = ?, keyword = ?, quiet_hours_start = ?, quiet_hours_end = ?, updated_at = ? WHERE id = ? AND user_id = ?" - ) - .bind( - input.name, - input.enabled ? 1 : 0, - input.target, - input.targetId, - input.eventTypesJson, - input.mailboxIdsJson, - input.keyword, - input.quietHoursStart, - input.quietHoursEnd, - updatedAt, - id, - userId - ) - .run(); - const row = await db.prepare("SELECT * FROM notification_rules WHERE id = ? AND user_id = ?").bind(id, userId).first(); - return row ? toNotificationRuleRecord(row) : null; - }, - async delete(id, userId) { - await db.prepare("DELETE FROM notification_rules WHERE id = ? AND user_id = ?").bind(id, userId).run(); - } - }, - announcements: { - async list() { - const result = await db.prepare("SELECT * FROM announcements ORDER BY pinned DESC, published_at DESC").all(); - return (result.results ?? []).map(toAnnouncementRecord); - }, - async listPage(options) { - const page = getSafePage(options.page); - const pageSize = getSafePageSize(options.pageSize); - const result = await db.prepare("SELECT * FROM announcements").all(); - const filteredAnnouncements = filterAnnouncements((result.results ?? []).map(toAnnouncementRecord), { - ...options, - page, - pageSize - }); - return { - announcements: paginateAnnouncements(filteredAnnouncements, { ...options, page, pageSize }), - total: filteredAnnouncements.length, - page, - pageSize - }; - }, - async listFeatured(options) { - const result = await db.prepare("SELECT * FROM announcements").all(); - return getFeaturedAnnouncements((result.results ?? []).map(toAnnouncementRecord), options); - }, - async summary(options) { - const result = await db.prepare("SELECT * FROM announcements").all(); - return getAnnouncementSummary((result.results ?? []).map(toAnnouncementRecord), options); - }, - async create(input) { - const now = nowIso(); - const record = { id: crypto.randomUUID(), publishedAt: now, updatedAt: now, ...input }; - await db - .prepare( - "INSERT INTO announcements (id, title, summary, type, status, audience, priority, author_user_id, author_label, tags_json, pinned, start_at, end_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" - ) - .bind( - record.id, - record.title, - record.summary, - record.type, - record.status, - record.audience, - record.priority, - record.authorUserId, - record.authorLabel, - record.tagsJson, - record.pinned ? 1 : 0, - record.startAt, - record.endAt, - record.publishedAt, - record.updatedAt - ) - .run(); - return record; - }, - async find(id) { - const row = await db.prepare("SELECT * FROM announcements WHERE id = ?").bind(id).first(); - return row ? toAnnouncementRecord(row) : null; - }, - async update(id, input) { - const existing = await db.prepare("SELECT * FROM announcements WHERE id = ?").bind(id).first(); - if (!existing) return null; - const current = toAnnouncementRecord(existing); - const record = { - ...current, - ...input, - updatedAt: nowIso() - }; - await db - .prepare( - "UPDATE announcements SET title = ?, summary = ?, type = ?, status = ?, audience = ?, priority = ?, tags_json = ?, pinned = ?, start_at = ?, end_at = ?, updated_at = ? WHERE id = ?" - ) - .bind( - record.title, - record.summary, - record.type, - record.status, - record.audience, - record.priority, - record.tagsJson, - record.pinned ? 1 : 0, - record.startAt, - record.endAt, - record.updatedAt, - id - ) - .run(); - return record; - }, - async delete(id) { - await db.prepare("DELETE FROM announcement_receipts WHERE announcement_id = ?").bind(id).run(); - const result = await db.prepare("DELETE FROM announcements WHERE id = ?").bind(id).run(); - return Number(result.meta.changes ?? 0) > 0; - }, - async acknowledge(announcementId, userId) { - const acknowledgedAt = nowIso(); - await db - .prepare( - "INSERT INTO announcement_receipts (announcement_id, user_id, acknowledged_at) VALUES (?, ?, ?) ON CONFLICT(announcement_id, user_id) DO UPDATE SET acknowledged_at = excluded.acknowledged_at" - ) - .bind(announcementId, userId, acknowledgedAt) - .run(); - return { announcementId, userId, acknowledgedAt }; - }, - async listReceiptsByUser(userId, announcementIds) { - if (announcementIds.length === 0) return []; - const placeholders = announcementIds.map(() => "?").join(", "); - const result = await db - .prepare(`SELECT * FROM announcement_receipts WHERE user_id = ? AND announcement_id IN (${placeholders})`) - .bind(userId, ...announcementIds) - .all(); - return (result.results ?? []).map(toAnnouncementReceiptRecord); - }, - async countReceipts(announcementIds) { - if (announcementIds.length === 0) return {}; - const placeholders = announcementIds.map(() => "?").join(", "); - const result = await db - .prepare(`SELECT announcement_id, COUNT(*) AS signed FROM announcement_receipts WHERE announcement_id IN (${placeholders}) GROUP BY announcement_id`) - .bind(...announcementIds) - .all<{ announcement_id: string; signed: number }>(); - return (result.results ?? []).reduce>((counts, row) => { - counts[row.announcement_id] = Number(row.signed ?? 0); - return counts; - }, {}); - } - } + ...createUsersAggregate(db), + ...createMailAggregate(db), + ...createSettingsAggregate(db), + ...createOpsAggregate(db), + ...createAnnouncementsAggregate(db) }; } diff --git a/apps/worker/src/infrastructure/persistence/d1/announcement-sql.ts b/apps/worker/src/infrastructure/persistence/d1/announcement-sql.ts new file mode 100644 index 0000000..3a14468 --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/announcement-sql.ts @@ -0,0 +1,130 @@ +// --------------------------------------------------------------------------- +// Announcement SQL pushdown. +// +// The WHERE fragments below must stay semantically identical to +// src/shared/announcements.ts (isAnnouncementVisible + matchesAnnouncementFilters). +// ISO-8601 text compares lexicographically == chronologically in SQLite. +// Manage scope + admin bypasses every check *including* the archived +// exclusion, exactly like isAnnouncementVisible. +// +// Known accepted divergence: SQLite lower() folds ASCII only, while the JS +// helpers use Unicode toLowerCase(). Announcement content is Chinese text +// (caseless), so this only matters for accented Latin keywords. +// --------------------------------------------------------------------------- + +import { DAY_MS, nowIso } from "./shared"; + +export type AnnouncementSqlFilter = { + whereSql: string; + params: (string | number)[]; +}; + +function buildAnnouncementStatusPredicate(status: string) { + // Mirrors resolveAnnouncementStatus branch-for-branch for non-archived rows, + // in the same order: upcoming is decided by start alone, ended by end alone. + if (status === "已发布") { + return "(status <> '已归档' AND start_at IS NULL AND end_at IS NULL)"; + } + if (status === "即将开始") { + return "(status <> '已归档' AND start_at IS NOT NULL AND start_at > ?)"; + } + if (status === "已结束") { + return "(status <> '已归档' AND end_at IS NOT NULL AND end_at < ?)"; + } + if (status === "进行中") { + return ( + "(status <> '已归档' AND (start_at IS NOT NULL OR end_at IS NOT NULL) AND " + + "(start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at >= ?))" + ); + } + return "(status = ?)"; +} + +function buildAnnouncementVisibilityPredicate( + scope: "visible" | "manage" | undefined, + userRole: "admin" | "member" | undefined, + nowIsoValue: string +) { + if (scope === "manage" && userRole === "admin") { + return { sql: "1 = 1", params: [] as (string | number)[] }; + } + const parts: string[] = ["status <> '已归档'"]; + const params: (string | number)[] = []; + if (!userRole) { + return { sql: "1 = 0", params }; + } + // isAnnouncementAudienceVisible: 管理员→admin, 普通成员→member, else all. + parts.push("(audience NOT IN ('管理员', '普通成员') OR audience = ?)"); + params.push(userRole === "admin" ? "管理员" : "普通成员"); + // isWithinPublishWindow. Invariant: start_at/end_at are NULL or valid ISO + // strings (the API normalizes "" to NULL; migration 0021 cleaned old rows). + parts.push("(start_at IS NULL OR start_at <= ?) AND (end_at IS NULL OR end_at >= ?)"); + params.push(nowIsoValue, nowIsoValue); + return { sql: parts.join(" AND "), params }; +} + +// Reconstructs the exact string the JS helper searches: +// `${title} ${summary} ${tags.join(" ")}`. json_each/guard conditions mirror +// parseAnnouncementTags: non-array JSON yields no tags, non-string entries are +// skipped. instr() is a plain substring search with no wildcard metacharacters, +// so the keyword is bound as-is (escaping would wrongly hide literal %, _, \). +const ANNOUNCEMENT_KEYWORD_PREDICATE = + "instr(lower(" + + "title || ' ' || summary || ' ' || " + + "COALESCE((" + + "SELECT group_concat(json_each.value, ' ') FROM json_each(" + + "CASE WHEN json_valid(announcements.tags_json) AND json_type(announcements.tags_json) = 'array' " + + "THEN announcements.tags_json ELSE '[]' END" + + ") WHERE json_each.type = 'text'" + + "), '')" + + "), ?) > 0"; + +export function buildAnnouncementFilter(options: { + q?: string; + scope?: "visible" | "manage"; + status?: string; + time?: "7d" | "30d"; + type?: string; + userRole?: "admin" | "member"; +}): AnnouncementSqlFilter { + const nowIsoValue = nowIso(); + const parts: string[] = []; + const params: (string | number)[] = []; + + const visibility = buildAnnouncementVisibilityPredicate(options.scope, options.userRole, nowIsoValue); + parts.push(visibility.sql); + params.push(...visibility.params); + + const keyword = options.q?.trim().toLowerCase(); + if (keyword) { + parts.push(ANNOUNCEMENT_KEYWORD_PREDICATE); + params.push(keyword); + } + + if (options.type) { + parts.push("type = ?"); + params.push(options.type); + } + + if (options.status) { + const statusPredicate = buildAnnouncementStatusPredicate(options.status); + parts.push(statusPredicate); + if (statusPredicate === "(status = ?)") { + params.push(options.status); + } else { + // 已发布 has no placeholders; the other derived statuses bind `now` once + // or twice, which the placeholder count already encodes. + const nowCount = (statusPredicate.match(/\?/g) ?? []).length; + for (let i = 0; i < nowCount; i += 1) params.push(nowIsoValue); + } + } + + if (options.time) { + const days = options.time === "7d" ? 7 : 30; + const cutoff = new Date(Date.now() - days * DAY_MS).toISOString(); + parts.push("published_at >= ?"); + params.push(cutoff); + } + + return { whereSql: `WHERE ${parts.join(" AND ")}`, params }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/announcements.ts b/apps/worker/src/infrastructure/persistence/d1/announcements.ts new file mode 100644 index 0000000..47f6120 --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/announcements.ts @@ -0,0 +1,180 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { announcementStatuses } from "../../../shared/announcements"; +import type { AppStore } from "../../../core/bindings"; +import { buildAnnouncementFilter } from "./announcement-sql"; +import { toAnnouncementRecord, toAnnouncementReceiptRecord } from "./row-mappers"; +import { getSafePage, getSafePageSize, nowIso } from "./shared"; + +type AnnouncementsAggregate = Pick; + +export function createAnnouncementsAggregate(db: D1Database): AnnouncementsAggregate { + return { + announcements: { + async list() { + const result = await db.prepare("SELECT * FROM announcements ORDER BY pinned DESC, published_at DESC").all(); + return (result.results ?? []).map(toAnnouncementRecord); + }, + async listPage(options) { + const page = getSafePage(options.page); + const pageSize = getSafePageSize(options.pageSize); + // Filtering and pagination are pushed into SQL; the JS helpers in + // shared/announcements.ts remain the source of truth for semantics and + // are exercised against the in-memory store in tests. + const filter = buildAnnouncementFilter(options); + const baseParams = [...filter.params]; + const countResult = await db + .prepare(`SELECT COUNT(*) AS count FROM announcements ${filter.whereSql}`) + .bind(...baseParams) + .first<{ count: number }>(); + const rows = await db + .prepare( + `SELECT * FROM announcements ${filter.whereSql} ORDER BY pinned DESC, published_at DESC LIMIT ? OFFSET ?` + ) + .bind(...baseParams, pageSize, (page - 1) * pageSize) + .all(); + return { + announcements: (rows.results ?? []).map(toAnnouncementRecord), + total: countResult?.count ?? 0, + page, + pageSize + }; + }, + async listFeatured(options) { + const filter = buildAnnouncementFilter(options); + const rows = await db + .prepare( + `SELECT * FROM announcements ${filter.whereSql} AND pinned = 1 ORDER BY pinned DESC, published_at DESC` + ) + .bind(...filter.params) + .all(); + return (rows.results ?? []).map(toAnnouncementRecord); + }, + async summary(options) { + // Group the derived status in SQL with the same predicates the JS + // resolver uses, falling back to the stored status for 已归档. Branch + // order matches resolveAnnouncementStatus: upcoming is decided by + // start_at alone, before the ended check. + const filter = buildAnnouncementFilter(options); + const nowIsoValue = nowIso(); + const rows = await db + .prepare( + `SELECT + CASE + WHEN status = '已归档' THEN '已归档' + WHEN start_at IS NOT NULL AND start_at > ? THEN '即将开始' + WHEN end_at IS NOT NULL AND end_at < ? THEN '已结束' + WHEN start_at IS NULL AND end_at IS NULL THEN '已发布' + ELSE '进行中' + END AS derived_status, + COUNT(*) AS count + FROM announcements ${filter.whereSql} + GROUP BY derived_status` + ) + .bind(nowIsoValue, nowIsoValue, ...filter.params) + .all(); + const counts = new Map(); + for (const row of (rows.results ?? []) as Array<{ derived_status: string; count: number }>) { + counts.set(row.derived_status, row.count); + } + return announcementStatuses.map((status) => ({ label: status, value: counts.get(status) ?? 0 })); + }, + async create(input) { + const now = nowIso(); + const record = { id: crypto.randomUUID(), publishedAt: now, updatedAt: now, ...input }; + await db + .prepare( + "INSERT INTO announcements (id, title, summary, type, status, audience, priority, author_user_id, author_label, tags_json, pinned, start_at, end_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + record.id, + record.title, + record.summary, + record.type, + record.status, + record.audience, + record.priority, + record.authorUserId, + record.authorLabel, + record.tagsJson, + record.pinned ? 1 : 0, + record.startAt, + record.endAt, + record.publishedAt, + record.updatedAt + ) + .run(); + return record; + }, + async find(id) { + const row = await db.prepare("SELECT * FROM announcements WHERE id = ?").bind(id).first(); + return row ? toAnnouncementRecord(row) : null; + }, + async update(id, input) { + const existing = await db.prepare("SELECT * FROM announcements WHERE id = ?").bind(id).first(); + if (!existing) return null; + const current = toAnnouncementRecord(existing); + const record = { + ...current, + ...input, + updatedAt: nowIso() + }; + await db + .prepare( + "UPDATE announcements SET title = ?, summary = ?, type = ?, status = ?, audience = ?, priority = ?, tags_json = ?, pinned = ?, start_at = ?, end_at = ?, updated_at = ? WHERE id = ?" + ) + .bind( + record.title, + record.summary, + record.type, + record.status, + record.audience, + record.priority, + record.tagsJson, + record.pinned ? 1 : 0, + record.startAt, + record.endAt, + record.updatedAt, + id + ) + .run(); + return record; + }, + async delete(id) { + await db.prepare("DELETE FROM announcement_receipts WHERE announcement_id = ?").bind(id).run(); + const result = await db.prepare("DELETE FROM announcements WHERE id = ?").bind(id).run(); + return Number(result.meta.changes ?? 0) > 0; + }, + async acknowledge(announcementId, userId) { + const acknowledgedAt = nowIso(); + await db + .prepare( + "INSERT INTO announcement_receipts (announcement_id, user_id, acknowledged_at) VALUES (?, ?, ?) ON CONFLICT(announcement_id, user_id) DO UPDATE SET acknowledged_at = excluded.acknowledged_at" + ) + .bind(announcementId, userId, acknowledgedAt) + .run(); + return { announcementId, userId, acknowledgedAt }; + }, + async listReceiptsByUser(userId, announcementIds) { + if (announcementIds.length === 0) return []; + const placeholders = announcementIds.map(() => "?").join(", "); + const result = await db + .prepare(`SELECT * FROM announcement_receipts WHERE user_id = ? AND announcement_id IN (${placeholders})`) + .bind(userId, ...announcementIds) + .all(); + return (result.results ?? []).map(toAnnouncementReceiptRecord); + }, + async countReceipts(announcementIds) { + if (announcementIds.length === 0) return {}; + const placeholders = announcementIds.map(() => "?").join(", "); + const result = await db + .prepare(`SELECT announcement_id, COUNT(*) AS signed FROM announcement_receipts WHERE announcement_id IN (${placeholders}) GROUP BY announcement_id`) + .bind(...announcementIds) + .all<{ announcement_id: string; signed: number }>(); + return (result.results ?? []).reduce>((counts, row) => { + counts[row.announcement_id] = Number(row.signed ?? 0); + return counts; + }, {}); + } + } + }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/mail.ts b/apps/worker/src/infrastructure/persistence/d1/mail.ts new file mode 100644 index 0000000..324f3cd --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/mail.ts @@ -0,0 +1,611 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { + AppStore, + AttachmentRecord, + MailboxDetailListQuery, + MailboxDetailRecord, + OutboundMessageUsageQuery, + OutboundMessageUsageSummary +} from "../../../core/bindings"; +import { toMailboxDetailRecord, toMailboxRecord, toMessageRecord, toOutboundMessageRecord } from "./row-mappers"; +import { DAY_MS, getActiveRangeDays, getInactiveDays, getSafePage, getSafePageSize, nowIso } from "./shared"; + +// Cloudflare D1 caps bound parameters per query at 100. IN (...) clauses with +// one placeholder per id must stay at or below that or the whole statement +// errors — the cleanup cron passes up to CLEANUP_BATCH_SIZE ids at once, and +// the batch-delete route passes user-selected ids. +const D1_BIND_PARAMETER_LIMIT = 100; + +function chunkForD1BindLimit(items: T[]): T[][] { + if (items.length <= D1_BIND_PARAMETER_LIMIT) return [items]; + const chunks: T[][] = []; + for (let offset = 0; offset < items.length; offset += D1_BIND_PARAMETER_LIMIT) { + chunks.push(items.slice(offset, offset + D1_BIND_PARAMETER_LIMIT)); + } + return chunks; +} + +type MailAggregate = Pick; + +export function createMailAggregate(db: D1Database): MailAggregate { + async function findMailboxDetailById(id: string): Promise { + const row = await db + .prepare( + ` + SELECT + a.id, + a.user_id, + a.address, + a.label, + a.status, + a.tags_json, + a.created_by_user_id, + a.last_active_at, + a.deleted_at, + a.created_at, + u.name as created_by_name, + (SELECT COUNT(*) FROM mail_messages WHERE account_id = a.id) as message_count, + (SELECT COUNT(*) FROM mail_outbound_messages WHERE account_id = a.id) as outbound_count + FROM accounts a + LEFT JOIN users u ON a.created_by_user_id = u.id + WHERE a.id = ? + ` + ) + .bind(id) + .first(); + + return row ? toMailboxDetailRecord(row) : null; + } + + return { + mailboxes: { + async countByUser(userId) { + const result = await db + .prepare("SELECT count(*) AS count FROM accounts WHERE user_id = ? AND status != 'soft_deleted'") + .bind(userId) + .first<{ count: number }>(); + return result?.count ?? 0; + }, + async create(input) { + const id = crypto.randomUUID(); + const createdAt = nowIso(); + await db + .prepare( + "INSERT INTO accounts (id, user_id, address, label, status, tags_json, created_by_user_id, last_active_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + id, + input.userId, + input.address, + input.label, + input.status ?? "enabled", + JSON.stringify(input.tags ?? []), + input.userId, + input.lastActiveAt ?? null, + createdAt + ) + .run(); + return { id, userId: input.userId, address: input.address, label: input.label, createdAt }; + }, + async update(id, input) { + const assignments: string[] = []; + const bindings: unknown[] = []; + + if (typeof input.label !== "undefined") { + assignments.push("label = ?"); + bindings.push(input.label); + } + + if (typeof input.status !== "undefined") { + assignments.push("status = ?"); + bindings.push(input.status); + assignments.push("deleted_at = ?"); + bindings.push(input.status === "soft_deleted" ? nowIso() : null); + } + + if (typeof input.deletedAt !== "undefined") { + assignments.push("deleted_at = ?"); + bindings.push(input.deletedAt); + } + + if (typeof input.lastActiveAt !== "undefined") { + assignments.push("last_active_at = ?"); + bindings.push(input.lastActiveAt); + } + + if (typeof input.tags !== "undefined") { + assignments.push("tags_json = ?"); + bindings.push(JSON.stringify(input.tags)); + } + + if (assignments.length === 0) return findMailboxDetailById(id); + + await db + .prepare(`UPDATE accounts SET ${assignments.join(", ")} WHERE id = ?`) + .bind(...bindings, id) + .run(); + return findMailboxDetailById(id); + }, + async listByUser(userId) { + const result = await db.prepare("SELECT * FROM accounts WHERE user_id = ? ORDER BY created_at DESC").bind(userId).all(); + return (result.results ?? []).map(toMailboxRecord); + }, + async listAllWithDetails(query: MailboxDetailListQuery) { + const page = getSafePage(query.page); + const pageSize = getSafePageSize(query.pageSize); + const offset = (page - 1) * pageSize; + const whereConditions: string[] = []; + const bindings: any[] = []; + + if (query.userId) { + whereConditions.push("a.user_id = ?"); + bindings.push(query.userId); + } + + if (query.search) { + whereConditions.push("(a.id LIKE ? OR a.label LIKE ? OR a.address LIKE ? OR u.name LIKE ?)"); + const searchPattern = `%${query.search}%`; + bindings.push(searchPattern, searchPattern, searchPattern, searchPattern); + } + + if (query.status && query.status !== "all") { + whereConditions.push("a.status = ?"); + bindings.push(query.status); + } + + if (query.createdBy && query.createdBy !== "all") { + whereConditions.push("u.name = ?"); + bindings.push(query.createdBy); + } + + const activeRangeDays = getActiveRangeDays(query.activeRange); + if (activeRangeDays) { + whereConditions.push("a.last_active_at IS NOT NULL AND a.last_active_at >= ?"); + bindings.push(new Date(Date.now() - activeRangeDays * DAY_MS).toISOString()); + } + + if (query.quickFilter === "anomaly") { + whereConditions.push("a.status <> 'enabled'"); + } + + if (query.quickFilter === "inactive") { + whereConditions.push("(a.last_active_at IS NULL OR a.last_active_at < ?)"); + bindings.push(new Date(Date.now() - getInactiveDays(query.inactiveDays) * DAY_MS).toISOString()); + } + + const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; + + const countQuery = ` + SELECT COUNT(*) as count + FROM accounts a + LEFT JOIN users u ON a.created_by_user_id = u.id + ${whereClause} + `; + const countResult = await db.prepare(countQuery).bind(...bindings).first<{ count: number }>(); + const total = countResult?.count ?? 0; + + const dataQuery = ` + SELECT + a.id, + a.user_id, + a.address, + a.label, + a.status, + a.tags_json, + a.created_by_user_id, + a.last_active_at, + a.deleted_at, + a.created_at, + u.name as created_by_name, + (SELECT COUNT(*) FROM mail_messages WHERE account_id = a.id) as message_count, + (SELECT COUNT(*) FROM mail_outbound_messages WHERE account_id = a.id) as outbound_count + FROM accounts a + LEFT JOIN users u ON a.created_by_user_id = u.id + ${whereClause} + ORDER BY a.created_at DESC + LIMIT ? OFFSET ? + `; + const dataResult = await db.prepare(dataQuery).bind(...bindings, pageSize, offset).all(); + + const accounts = (dataResult.results ?? []).map(toMailboxDetailRecord); + + return { accounts, total }; + }, + async listAll() { + const result = await db.prepare("SELECT * FROM accounts ORDER BY created_at DESC").all(); + return (result.results ?? []).map(toMailboxRecord); + }, + async listPage(options) { + const totalRow = await db.prepare("SELECT count(*) AS count FROM accounts").first<{ count: number }>(); + const latestRow = await db.prepare("SELECT * FROM accounts ORDER BY created_at DESC LIMIT 1").first(); + const result = await db + .prepare("SELECT * FROM accounts ORDER BY created_at DESC LIMIT ? OFFSET ?") + .bind(options.pageSize, (options.page - 1) * options.pageSize) + .all(); + + return { + latestMailbox: latestRow ? toMailboxRecord(latestRow) : null, + mailboxes: (result.results ?? []).map(toMailboxRecord), + page: options.page, + pageSize: options.pageSize, + total: totalRow?.count ?? 0 + }; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM accounts WHERE id = ?").bind(id).first(); + return row ? toMailboxRecord(row) : null; + }, + async findDetailById(id) { + return findMailboxDetailById(id); + }, + async findByAddress(address) { + const row = await db + .prepare("SELECT * FROM accounts WHERE address = ? AND status <> 'soft_deleted'") + .bind(address) + .first(); + return row ? toMailboxRecord(row) : null; + }, + async delete(id) { + await db.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run(); + } + }, + messages: { + async create(input) { + const id = crypto.randomUUID(); + await db + .prepare( + "INSERT INTO mail_messages (id, account_id, to_address, from_address, subject, preview_text, body_text, extraction_json, oversize_status, attachment_count, received_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + id, + input.mailboxId, + input.toAddress ?? null, + input.fromAddress, + input.subject, + input.previewText, + input.bodyText, + input.extractionJson, + input.oversizeStatus, + input.attachmentCount, + input.receivedAt, + input.expiresAt + ) + .run(); + return { id, ...input }; + }, + async listForMailboxes(query) { + const mailboxIds = Array.from(new Set(query.mailboxIds)).filter(Boolean); + const page = getSafePage(query.page); + const pageSize = getSafePageSize(query.pageSize); + + if (mailboxIds.length === 0 && !query.includeUnmatched) { + return { + messages: [], + page, + pageSize, + summary: { messageCount: 0, extractionCount: 0, attachmentCount: 0 }, + total: 0 + }; + } + + const bindings: unknown[] = []; + const scopeConditions: string[] = []; + + if (mailboxIds.length > 0) { + // D1 limits bound SQL variables; json_each keeps large mailbox scopes to one binding. + scopeConditions.push("account_id IN (SELECT value FROM json_each(?))"); + bindings.push(JSON.stringify(mailboxIds)); + } + if (query.includeUnmatched) { + scopeConditions.push("account_id LIKE 'unmatched:%'"); + } + + const whereConditions = [`(${scopeConditions.join(" OR ")})`]; + const search = query.search?.trim(); + + if (search) { + const searchLike = `%${search}%`; + whereConditions.push( + "(to_address LIKE ? COLLATE NOCASE OR from_address LIKE ? COLLATE NOCASE OR subject LIKE ? COLLATE NOCASE OR preview_text LIKE ? COLLATE NOCASE OR body_text LIKE ? COLLATE NOCASE OR extraction_json LIKE ? COLLATE NOCASE)" + ); + bindings.push(searchLike, searchLike, searchLike, searchLike, searchLike, searchLike); + } + + if (query.filter === "code") { + whereConditions.push("json_extract(extraction_json, '$.type') = 'auth_code'"); + } + if (query.filter === "link") { + whereConditions.push("json_extract(extraction_json, '$.type') NOT IN ('auth_code', 'none')"); + } + if (query.filter === "attachment") { + whereConditions.push("attachment_count > 0"); + } + if (query.filter === "unparsed") { + whereConditions.push("json_extract(extraction_json, '$.type') = 'none'"); + } + + const from = query.from?.trim(); + if (from) { + whereConditions.push("from_address LIKE ? COLLATE NOCASE"); + bindings.push(`%${from}%`); + } + + const subject = query.subject?.trim(); + if (subject) { + whereConditions.push("subject LIKE ? COLLATE NOCASE"); + bindings.push(`%${subject}%`); + } + + if (query.startDate) { + whereConditions.push("received_at >= ?"); + bindings.push(query.startDate); + } + + if (query.endDate) { + whereConditions.push("received_at <= ?"); + bindings.push(query.endDate); + } + + if (typeof query.hasAttachment === "boolean") { + whereConditions.push(query.hasAttachment ? "attachment_count > 0" : "attachment_count = 0"); + } + + if (query.extractionType) { + whereConditions.push("json_extract(extraction_json, '$.type') = ?"); + bindings.push(query.extractionType); + } + + const whereClause = `WHERE ${whereConditions.join(" AND ")}`; + const totalRow = await db + .prepare(`SELECT COUNT(*) AS count FROM mail_messages ${whereClause}`) + .bind(...bindings) + .first<{ count: number }>(); + const summaryRow = await db + .prepare( + `SELECT + COUNT(*) AS message_count, + COALESCE(SUM(CASE WHEN json_extract(extraction_json, '$.type') <> 'none' AND trim(COALESCE(json_extract(extraction_json, '$.value'), '')) <> '' THEN 1 ELSE 0 END), 0) AS extraction_count, + COALESCE(SUM(attachment_count), 0) AS attachment_count + FROM mail_messages ${whereClause}` + ) + .bind(...bindings) + .first<{ message_count: number; extraction_count: number; attachment_count: number }>(); + const result = await db + .prepare(`SELECT * FROM mail_messages ${whereClause} ORDER BY received_at DESC LIMIT ? OFFSET ?`) + .bind(...bindings, pageSize, (page - 1) * pageSize) + .all(); + + return { + messages: (result.results ?? []).map(toMessageRecord), + page, + pageSize, + summary: { + messageCount: Number(summaryRow?.message_count ?? 0), + extractionCount: Number(summaryRow?.extraction_count ?? 0), + attachmentCount: Number(summaryRow?.attachment_count ?? 0) + }, + total: Number(totalRow?.count ?? 0) + }; + }, + async listByMailbox(mailboxId) { + const result = await db + .prepare("SELECT * FROM mail_messages WHERE account_id = ? ORDER BY received_at DESC") + .bind(mailboxId) + .all(); + return (result.results ?? []).map(toMessageRecord); + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM mail_messages WHERE id = ?").bind(id).first(); + return row ? toMessageRecord(row) : null; + }, + async listExpired(beforeIso, options) { + // Ordered by the expiry index (see migration 0020) so the cleanup cron + // can page through bounded batches without a full-table scan. + const limit = options?.limit; + const sql = + "SELECT * FROM mail_messages WHERE expires_at <= ? ORDER BY expires_at ASC, id ASC" + + (limit !== undefined ? " LIMIT ?" : ""); + const statement = limit !== undefined + ? db.prepare(sql).bind(beforeIso, limit) + : db.prepare(sql).bind(beforeIso); + const result = await statement.all(); + return (result.results ?? []).map(toMessageRecord); + }, + async deleteMany(ids) { + if (ids.length === 0) return; + for (const chunk of chunkForD1BindLimit(ids)) { + const placeholders = chunk.map(() => "?").join(", "); + await db.prepare(`DELETE FROM mail_messages WHERE id IN (${placeholders})`).bind(...chunk).run(); + } + } + }, + attachments: { + async createMany(messageId, nextAttachments) { + for (const attachment of nextAttachments) { + await db + .prepare( + "INSERT INTO mail_attachments (id, message_id, filename, content_type, size, storage_key, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + attachment.id, + messageId, + attachment.filename, + attachment.contentType, + attachment.size, + attachment.key, + nowIso() + ) + .run(); + } + }, + async listByMessage(messageId) { + const result = await db + .prepare("SELECT id, filename, content_type, size, storage_key FROM mail_attachments WHERE message_id = ?") + .bind(messageId) + .all(); + return (result.results ?? []).map((row: any) => ({ + id: row.id, + filename: row.filename, + contentType: row.content_type, + size: Number(row.size), + key: row.storage_key + })) as AttachmentRecord[]; + }, + async listByMessageIds(messageIds) { + if (messageIds.length === 0) return []; + const results: AttachmentRecord[] = []; + for (const chunk of chunkForD1BindLimit(messageIds)) { + const placeholders = chunk.map(() => "?").join(", "); + const result = await db + .prepare(`SELECT id, filename, content_type, size, storage_key FROM mail_attachments WHERE message_id IN (${placeholders})`) + .bind(...chunk) + .all(); + results.push( + ...((result.results ?? []).map((row: any) => ({ + id: row.id, + filename: row.filename, + contentType: row.content_type, + size: Number(row.size), + key: row.storage_key + })) as AttachmentRecord[]) + ); + } + return results; + }, + async deleteByMessageIds(messageIds) { + if (messageIds.length === 0) return; + for (const chunk of chunkForD1BindLimit(messageIds)) { + const placeholders = chunk.map(() => "?").join(", "); + await db.prepare(`DELETE FROM mail_attachments WHERE message_id IN (${placeholders})`).bind(...chunk).run(); + } + } + }, + outboundMessages: { + async create(input) { + const id = crypto.randomUUID(); + const createdAt = nowIso(); + await db + .prepare( + "INSERT INTO mail_outbound_messages (id, account_id, from_address, to_address, subject, body_text, status, error_text, provider_message_id, request_payload_json, response_payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + id, + input.mailboxId, + input.fromAddress, + input.toAddress, + input.subject, + input.bodyText, + input.status, + input.errorText, + input.providerMessageId, + input.requestPayloadJson, + input.responsePayloadJson, + createdAt + ) + .run(); + return { + id, + createdAt, + ...input + }; + }, + async listByMailbox(query) { + const page = getSafePage(query.page); + const pageSize = getSafePageSize(query.pageSize); + const baseBindings: unknown[] = [query.mailboxId]; + const baseConditions = ["account_id = ?"]; + const search = query.search?.trim(); + + if (search) { + const searchLike = `%${search}%`; + baseConditions.push( + "(from_address LIKE ? COLLATE NOCASE OR to_address LIKE ? COLLATE NOCASE OR subject LIKE ? COLLATE NOCASE OR body_text LIKE ? COLLATE NOCASE OR error_text LIKE ? COLLATE NOCASE OR provider_message_id LIKE ? COLLATE NOCASE OR request_payload_json LIKE ? COLLATE NOCASE OR response_payload_json LIKE ? COLLATE NOCASE)" + ); + baseBindings.push(searchLike, searchLike, searchLike, searchLike, searchLike, searchLike, searchLike, searchLike); + } + + const filteredConditions = [...baseConditions]; + const filteredBindings = [...baseBindings]; + if (query.status === "sent" || query.status === "failed") { + filteredConditions.push("status = ?"); + filteredBindings.push(query.status); + } + + const baseWhereClause = `WHERE ${baseConditions.join(" AND ")}`; + const filteredWhereClause = `WHERE ${filteredConditions.join(" AND ")}`; + const totalRow = await db + .prepare(`SELECT COUNT(*) AS count FROM mail_outbound_messages ${filteredWhereClause}`) + .bind(...filteredBindings) + .first<{ count: number }>(); + const summaryRow = await db + .prepare( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0) AS sent_count, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count + FROM mail_outbound_messages ${baseWhereClause}` + ) + .bind(...baseBindings) + .first<{ total_count: number; sent_count: number; failed_count: number }>(); + const result = await db + .prepare(`SELECT * FROM mail_outbound_messages ${filteredWhereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`) + .bind(...filteredBindings, pageSize, (page - 1) * pageSize) + .all(); + + return { + messages: (result.results ?? []).map(toOutboundMessageRecord), + page, + pageSize, + summary: { + totalCount: Number(summaryRow?.total_count ?? 0), + sentCount: Number(summaryRow?.sent_count ?? 0), + failedCount: Number(summaryRow?.failed_count ?? 0) + }, + total: Number(totalRow?.count ?? 0) + }; + }, + async summarizeByMailboxes(query: OutboundMessageUsageQuery): Promise { + const mailboxIds = Array.from(new Set(query.mailboxIds)).filter(Boolean); + if (mailboxIds.length === 0) { + return { + totalCount: 0, + sentCount: 0, + failedCount: 0, + sentSinceCount: 0 + }; + } + + const sentSinceExpression = query.sinceIso + ? "COALESCE(SUM(CASE WHEN status = 'sent' AND created_at >= ? THEN 1 ELSE 0 END), 0)" + : "COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0)"; + const bindings: unknown[] = query.sinceIso + ? [query.sinceIso, JSON.stringify(mailboxIds)] + : [JSON.stringify(mailboxIds)]; + // Admin summaries only need counts; materializing outbound bodies makes this request grow with mailbox history. + const row = await db + .prepare( + `SELECT + COUNT(*) AS total_count, + COALESCE(SUM(CASE WHEN status = 'sent' THEN 1 ELSE 0 END), 0) AS sent_count, + COALESCE(SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END), 0) AS failed_count, + ${sentSinceExpression} AS sent_since_count + FROM mail_outbound_messages + WHERE account_id IN (SELECT value FROM json_each(?))` + ) + .bind(...bindings) + .first<{ total_count: number; sent_count: number; failed_count: number; sent_since_count: number }>(); + + return { + totalCount: Number(row?.total_count ?? 0), + sentCount: Number(row?.sent_count ?? 0), + failedCount: Number(row?.failed_count ?? 0), + sentSinceCount: Number(row?.sent_since_count ?? 0) + }; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM mail_outbound_messages WHERE id = ?").bind(id).first(); + return row ? toOutboundMessageRecord(row) : null; + } + } + }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/ops.ts b/apps/worker/src/infrastructure/persistence/d1/ops.ts new file mode 100644 index 0000000..f29865d --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/ops.ts @@ -0,0 +1,427 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { AppStore, NotificationRuleRecord } from "../../../core/bindings"; +import { toNotificationRuleRecord } from "./row-mappers"; +import { getSafePageSize, nowIso, toBool } from "./shared"; + +type OpsAggregate = Pick; + +export function createOpsAggregate(db: D1Database): OpsAggregate { + return { + audit: { + async record(event) { + await db + .prepare( + "INSERT INTO system_audit_events (id, actor_type, actor_id, event_type, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(crypto.randomUUID(), event.actorType, event.actorId, event.eventType, event.payloadJson, nowIso()) + .run(); + }, + async countByActorSince(actorId, eventType, sinceIso) { + const result = await db + .prepare( + "SELECT count(*) AS count FROM system_audit_events WHERE actor_id = ? AND event_type = ? AND created_at >= ?" + ) + .bind(actorId, eventType, sinceIso) + .first<{ count: number }>(); + return result?.count ?? 0; + }, + async listByActorAndTypes(actorId, eventTypes, limit) { + if (eventTypes.length === 0) return []; + const placeholders = eventTypes.map(() => "?").join(", "); + const result = await db + .prepare( + `SELECT * FROM system_audit_events WHERE actor_id = ? AND event_type IN (${placeholders}) ORDER BY created_at DESC LIMIT ?` + ) + .bind(actorId, ...eventTypes, limit) + .all(); + return (result.results ?? []).map((row) => ({ + id: row.id, + actorType: row.actor_type, + actorId: row.actor_id, + eventType: row.event_type, + payloadJson: row.payload_json, + createdAt: row.created_at + })); + }, + async listRecent(options) { + const eventTypes = options?.eventTypes ?? []; + const limit = Math.min(Math.max(Math.trunc(options?.limit ?? 30), 1), 500); + const whereClause = eventTypes.length > 0 ? `WHERE event_type IN (${eventTypes.map(() => "?").join(", ")})` : ""; + const result = await db + .prepare(`SELECT * FROM system_audit_events ${whereClause} ORDER BY created_at DESC LIMIT ?`) + .bind(...eventTypes, limit) + .all(); + return (result.results ?? []).map((row) => ({ + id: row.id, + actorType: row.actor_type, + actorId: row.actor_id, + eventType: row.event_type, + payloadJson: row.payload_json, + createdAt: row.created_at + })); + } + }, + cleanupRuns: { + async record(input) { + const id = crypto.randomUUID(); + await db + .prepare( + "INSERT INTO system_cleanup_runs (id, status, started_at, finished_at, deleted_messages, deleted_attachments, deleted_accounts, error_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + id, + input.status, + input.startedAt, + input.finishedAt, + input.deletedMessages, + input.deletedAttachments, + input.deletedAccounts, + input.errorText + ) + .run(); + return { id, ...input }; + }, + async listRecent(limit) { + const result = await db + .prepare("SELECT * FROM system_cleanup_runs ORDER BY started_at DESC LIMIT ?") + .bind(getSafePageSize(limit)) + .all(); + return (result.results ?? []).map((row) => ({ + id: row.id, + status: row.status === "failed" ? "failed" : "success", + startedAt: row.started_at, + finishedAt: row.finished_at, + deletedMessages: Number(row.deleted_messages ?? 0), + deletedAttachments: Number(row.deleted_attachments ?? 0), + deletedAccounts: Number(row.deleted_accounts ?? 0), + errorText: row.error_text ?? null + })); + } + }, + telegram: { + async upsert(input) { + const existing = await db + .prepare("SELECT * FROM telegram_subscriptions WHERE user_id = ?") + .bind(input.userId) + .first(); + const createdAt = existing?.created_at ?? nowIso(); + const id = existing?.id ?? crypto.randomUUID(); + const updatedAt = nowIso(); + await db + .prepare("DELETE FROM telegram_subscriptions WHERE chat_id = ? AND user_id <> ?") + .bind(input.chatId, input.userId) + .run(); + await db + .prepare( + "INSERT OR REPLACE INTO telegram_subscriptions (id, user_id, chat_id, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(id, input.userId, input.chatId, input.enabled ? 1 : 0, createdAt, updatedAt) + .run(); + return { id, userId: input.userId, chatId: input.chatId, enabled: input.enabled, createdAt, updatedAt }; + }, + async findByUserId(userId) { + const row = await db + .prepare("SELECT * FROM telegram_subscriptions WHERE user_id = ?") + .bind(userId) + .first(); + return row + ? { + id: row.id, + userId: row.user_id, + chatId: row.chat_id, + enabled: toBool(row.enabled), + createdAt: row.created_at, + updatedAt: row.updated_at + } + : null; + }, + async findByChatId(chatId) { + const row = await db + .prepare("SELECT * FROM telegram_subscriptions WHERE chat_id = ? ORDER BY updated_at DESC LIMIT 1") + .bind(chatId) + .first(); + return row + ? { + id: row.id, + userId: row.user_id, + chatId: row.chat_id, + enabled: toBool(row.enabled), + createdAt: row.created_at, + updatedAt: row.updated_at + } + : null; + } + }, + webhookEndpoints: { + async listByUser(userId) { + const result = await db + .prepare("SELECT * FROM webhook_endpoints WHERE user_id = ? ORDER BY created_at DESC") + .bind(userId) + .all(); + return (result.results ?? []).map((row: any) => ({ + id: row.id, + userId: row.user_id, + name: row.name, + url: row.url, + eventsJson: row.events_json, + signingSecret: row.signing_secret, + enabled: toBool(row.enabled), + createdAt: row.created_at, + updatedAt: row.updated_at + })); + }, + async listByUserPage(userId, options) { + const totalRow = await db + .prepare("SELECT count(*) AS count FROM webhook_endpoints WHERE user_id = ?") + .bind(userId) + .first<{ count: number }>(); + const result = await db + .prepare("SELECT * FROM webhook_endpoints WHERE user_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?") + .bind(userId, options.pageSize, (options.page - 1) * options.pageSize) + .all(); + return { + endpoints: (result.results ?? []).map((row: any) => ({ + id: row.id, + userId: row.user_id, + name: row.name, + url: row.url, + eventsJson: row.events_json, + signingSecret: row.signing_secret, + enabled: toBool(row.enabled), + createdAt: row.created_at, + updatedAt: row.updated_at + })), + total: Number(totalRow?.count ?? 0), + page: options.page, + pageSize: options.pageSize + }; + }, + async create(input) { + const now = nowIso(); + const record = { + id: crypto.randomUUID(), + userId: input.userId, + name: input.name, + url: input.url, + eventsJson: input.eventsJson, + signingSecret: crypto.randomUUID().replaceAll("-", ""), + enabled: input.enabled, + createdAt: now, + updatedAt: now + }; + await db + .prepare( + "INSERT INTO webhook_endpoints (id, user_id, name, url, events_json, signing_secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + record.id, + record.userId, + record.name, + record.url, + record.eventsJson, + record.signingSecret, + record.enabled ? 1 : 0, + record.createdAt, + record.updatedAt + ) + .run(); + return record; + }, + async update(id, userId, input) { + const updatedAt = nowIso(); + await db + .prepare( + "UPDATE webhook_endpoints SET name = ?, url = ?, events_json = ?, enabled = ?, updated_at = ? WHERE id = ? AND user_id = ?" + ) + .bind(input.name, input.url, input.eventsJson, input.enabled ? 1 : 0, updatedAt, id, userId) + .run(); + return (await this.listByUser(userId)).find((endpoint) => endpoint.id === id) ?? null; + }, + async rotateSecret(id, userId) { + const updatedAt = nowIso(); + const signingSecret = crypto.randomUUID().replaceAll("-", ""); + await db + .prepare("UPDATE webhook_endpoints SET signing_secret = ?, updated_at = ? WHERE id = ? AND user_id = ?") + .bind(signingSecret, updatedAt, id, userId) + .run(); + return (await this.listByUser(userId)).find((endpoint) => endpoint.id === id) ?? null; + }, + async delete(id, userId) { + const existing = (await this.listByUser(userId)).find((endpoint) => endpoint.id === id); + if (!existing) return; + await db.prepare("DELETE FROM webhook_deliveries WHERE endpoint_id = ?").bind(id).run(); + await db.prepare("DELETE FROM webhook_endpoints WHERE id = ? AND user_id = ?").bind(id, userId).run(); + } + }, + webhookDeliveries: { + async listByUser(userId) { + const result = await db + .prepare( + "SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE e.user_id = ? ORDER BY d.created_at DESC" + ) + .bind(userId) + .all(); + return (result.results ?? []).map((row: any) => ({ + id: row.id, + endpointId: row.endpoint_id, + eventType: row.event_type, + status: row.status, + statusCode: row.status_code === null ? null : Number(row.status_code), + durationMs: row.duration_ms === null ? null : Number(row.duration_ms), + errorText: row.error_text, + payloadJson: row.payload_json, + responseText: row.response_text ?? null, + createdAt: row.created_at + })); + }, + async listByUserPage(userId, query) { + const filters = ["e.user_id = ?"]; + const bindings: Array = [userId]; + if (query.endpointId) { + filters.push("d.endpoint_id = ?"); + bindings.push(query.endpointId); + } + if (query.status && query.status !== "all") { + filters.push("d.status = ?"); + bindings.push(query.status); + } + const whereClause = filters.join(" AND "); + const totalRow = await db + .prepare(`SELECT count(*) AS count FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE ${whereClause}`) + .bind(...bindings) + .first<{ count: number }>(); + const result = await db + .prepare( + `SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE ${whereClause} ORDER BY d.created_at DESC LIMIT ? OFFSET ?` + ) + .bind(...bindings, query.pageSize, (query.page - 1) * query.pageSize) + .all(); + return { + deliveries: (result.results ?? []).map((row: any) => ({ + id: row.id, + endpointId: row.endpoint_id, + eventType: row.event_type, + status: row.status, + statusCode: row.status_code === null ? null : Number(row.status_code), + durationMs: row.duration_ms === null ? null : Number(row.duration_ms), + errorText: row.error_text, + payloadJson: row.payload_json, + responseText: row.response_text ?? null, + createdAt: row.created_at + })), + total: Number(totalRow?.count ?? 0), + page: query.page, + pageSize: query.pageSize + }; + }, + async findByUser(id, userId) { + const row = await db + .prepare( + "SELECT d.* FROM webhook_deliveries d INNER JOIN webhook_endpoints e ON e.id = d.endpoint_id WHERE d.id = ? AND e.user_id = ?" + ) + .bind(id, userId) + .first(); + if (!row) return null; + return { + id: row.id, + endpointId: row.endpoint_id, + eventType: row.event_type, + status: row.status, + statusCode: row.status_code === null ? null : Number(row.status_code), + durationMs: row.duration_ms === null ? null : Number(row.duration_ms), + errorText: row.error_text, + payloadJson: row.payload_json, + responseText: row.response_text ?? null, + createdAt: row.created_at + }; + }, + async record(input) { + const { createdAt, id, ...recordInput } = input; + const record = { id: id ?? crypto.randomUUID(), createdAt: createdAt ?? nowIso(), ...recordInput }; + await db + .prepare( + "INSERT INTO webhook_deliveries (id, endpoint_id, event_type, status, status_code, duration_ms, error_text, payload_json, response_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + record.id, + record.endpointId, + record.eventType, + record.status, + record.statusCode, + record.durationMs, + record.errorText, + record.payloadJson, + record.responseText, + record.createdAt + ) + .run(); + return record; + } + }, + notificationRules: { + async listByUser(userId) { + const result = await db + .prepare("SELECT * FROM notification_rules WHERE user_id = ? ORDER BY updated_at DESC") + .bind(userId) + .all(); + return (result.results ?? []).map(toNotificationRuleRecord); + }, + async create(input) { + const record: NotificationRuleRecord = { + id: crypto.randomUUID(), + createdAt: nowIso(), + updatedAt: nowIso(), + ...input + }; + await db + .prepare( + "INSERT INTO notification_rules (id, user_id, name, enabled, target, target_id, event_types_json, mailbox_ids_json, keyword, quiet_hours_start, quiet_hours_end, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + record.id, + record.userId, + record.name, + record.enabled ? 1 : 0, + record.target, + record.targetId, + record.eventTypesJson, + record.mailboxIdsJson, + record.keyword, + record.quietHoursStart, + record.quietHoursEnd, + record.createdAt, + record.updatedAt + ) + .run(); + return record; + }, + async update(id, userId, input) { + const updatedAt = nowIso(); + await db + .prepare( + "UPDATE notification_rules SET name = ?, enabled = ?, target = ?, target_id = ?, event_types_json = ?, mailbox_ids_json = ?, keyword = ?, quiet_hours_start = ?, quiet_hours_end = ?, updated_at = ? WHERE id = ? AND user_id = ?" + ) + .bind( + input.name, + input.enabled ? 1 : 0, + input.target, + input.targetId, + input.eventTypesJson, + input.mailboxIdsJson, + input.keyword, + input.quietHoursStart, + input.quietHoursEnd, + updatedAt, + id, + userId + ) + .run(); + const row = await db.prepare("SELECT * FROM notification_rules WHERE id = ? AND user_id = ?").bind(id, userId).first(); + return row ? toNotificationRuleRecord(row) : null; + }, + async delete(id, userId) { + await db.prepare("DELETE FROM notification_rules WHERE id = ? AND user_id = ?").bind(id, userId).run(); + } + } + }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts b/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts new file mode 100644 index 0000000..8f4d839 --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/row-mappers.ts @@ -0,0 +1,269 @@ +import type { + AnnouncementRecord, + AnnouncementReceiptRecord, + NotificationRuleRecord, + OAuthIdentityRecord, + OAuthPendingLoginRecord, + OAuthStateRecord, + OutboundMessageRecord, + PersistedMessageRecord, + SessionRecord, + UserPreferencesRecord +} from "../../../core/bindings"; +import { parseApiKeyScopes, parseJson, toBool } from "./shared"; +import type { DictionaryGroupSummary, DictionaryItemSummary, UserStatus } from "@wemail/shared"; +import type { MailboxDetailRecord, UserRecord, InviteRecord } from "../../../core/bindings"; + +export function parseOAuthProvider(value: unknown) { + return value === "linuxdo" ? "linuxdo" : "github"; +} + +export function parseUserStatus(value: unknown): UserStatus { + return value === "disabled" || value === "outbound_disabled" ? "disabled" : "active"; +} + +export function parseUserRole(value: unknown) { + return value === "admin" ? "admin" : "member"; +} + + +export function toOAuthIdentityRecord(row: any): OAuthIdentityRecord { + return { + id: row.id, + userId: row.user_id, + provider: parseOAuthProvider(row.provider), + providerUserId: row.provider_user_id, + providerEmail: row.provider_email, + providerLogin: row.provider_login ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function toOAuthStateRecord(row: any): OAuthStateRecord { + return { + id: row.id, + provider: parseOAuthProvider(row.provider), + redirectTo: row.redirect_to, + expiresAt: row.expires_at, + createdAt: row.created_at + }; +} + +export function toOAuthPendingLoginRecord(row: any): OAuthPendingLoginRecord { + return { + id: row.id, + provider: parseOAuthProvider(row.provider), + providerUserId: row.provider_user_id, + providerEmail: row.provider_email, + providerName: row.provider_name, + providerLogin: row.provider_login ?? null, + redirectTo: row.redirect_to, + expiresAt: row.expires_at, + createdAt: row.created_at + }; +} + + + +export function toMessageRecord(row: any): PersistedMessageRecord { + return { + id: row.id, + mailboxId: row.account_id, + toAddress: row.to_address ?? null, + fromAddress: row.from_address, + subject: row.subject, + previewText: row.preview_text, + bodyText: row.body_text, + extractionJson: row.extraction_json, + oversizeStatus: row.oversize_status, + attachmentCount: Number(row.attachment_count), + receivedAt: row.received_at, + expiresAt: row.expires_at + }; +} + +export function toNotificationRuleRecord(row: any): NotificationRuleRecord { + return { + id: row.id, + userId: row.user_id, + name: row.name, + enabled: Boolean(row.enabled), + target: row.target, + targetId: row.target_id ?? null, + eventTypesJson: row.event_types_json, + mailboxIdsJson: row.mailbox_ids_json, + keyword: row.keyword, + quietHoursStart: row.quiet_hours_start, + quietHoursEnd: row.quiet_hours_end, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +export function toAnnouncementRecord(row: any): AnnouncementRecord { + return { + id: row.id, + title: row.title, + summary: row.summary, + type: row.type, + status: row.status, + audience: row.audience, + priority: row.priority, + authorUserId: row.author_user_id, + authorLabel: row.author_label, + tagsJson: row.tags_json, + pinned: toBool(row.pinned), + startAt: row.start_at, + endAt: row.end_at, + publishedAt: row.published_at, + updatedAt: row.updated_at + }; +} + +export function toAnnouncementReceiptRecord(row: any): AnnouncementReceiptRecord { + return { + announcementId: row.announcement_id, + userId: row.user_id, + acknowledgedAt: row.acknowledged_at + }; +} + +export function toOutboundMessageRecord(row: any): OutboundMessageRecord { + return { + id: row.id, + mailboxId: row.account_id, + fromAddress: row.from_address ?? "", + toAddress: row.to_address, + subject: row.subject, + bodyText: row.body_text ?? "", + status: row.status === "failed" ? "failed" : "sent", + errorText: row.error_text, + providerMessageId: row.provider_message_id ?? null, + requestPayloadJson: row.request_payload_json ?? "{}", + responsePayloadJson: row.response_payload_json ?? null, + createdAt: row.created_at + }; +} + +export function toUserRecord(row: any): UserRecord { + const email = String(row.email); + return { + id: row.id, + email, + name: row.name || email.split("@")[0] || email, + passwordHash: row.password_hash, + role: row.role, + status: parseUserStatus(row.status), + createdAt: row.created_at, + updatedAt: row.updated_at || row.created_at + }; +} + +export function toUserPreferencesRecord(row: any): UserPreferencesRecord { + return { + userId: row.user_id, + bio: row.bio || "", + locale: row.locale || "zh-CN", + timezone: row.timezone || "Asia/Shanghai", + dateFormat: row.date_format || "yyyy-mm-dd", + landingPage: row.landing_page || "/dashboard", + density: row.density || "comfortable", + updatedAt: row.updated_at + }; +} + +export function toSessionRecord(row: any): SessionRecord { + return { + id: row.id, + userId: row.user_id, + userAgent: row.user_agent ?? null, + ipAddress: row.ip_address ?? null, + expiresAt: row.expires_at, + lastSeenAt: row.last_seen_at ?? row.created_at, + createdAt: row.created_at + }; +} + +export function toInviteRecord(row: any): InviteRecord { + return { + id: row.id, + code: row.code, + createdByUserId: row.created_by_user_id, + redeemedByUserId: row.redeemed_by_user_id, + redeemedByUserName: row.redeemed_by_user_name || row.redeemed_by_user_email || null, + redeemedAt: row.redeemed_at, + disabledAt: row.disabled_at, + expiresAt: row.expires_at ?? null, + targetRole: parseUserRole(row.target_role), + maxRedemptions: Number(row.max_redemptions ?? 1), + redemptionCount: Number(row.redemption_count ?? (row.redeemed_at ? 1 : 0)), + createdAt: row.created_at + }; +} + +export function toApiKeyRecord(row: any) { + return { + id: row.id, + userId: row.user_id, + label: row.label, + prefix: row.prefix, + scopes: parseApiKeyScopes(row.scopes_json), + keyHash: row.key_hash, + createdAt: row.created_at, + lastUsedAt: row.last_used_at, + revokedAt: row.revoked_at + }; +} + +export function toMailboxRecord(row: any) { + return { + id: row.id, + userId: row.user_id, + address: row.address, + label: row.label, + createdAt: row.created_at + }; +} + +export function toMailboxDetailRecord(row: any): MailboxDetailRecord { + return { + id: row.id, + userId: row.user_id, + address: row.address, + label: row.label, + status: row.status || "enabled", + tags: JSON.parse(row.tags_json || "[]"), + createdBy: row.created_by_user_id, + createdByName: row.created_by_name, + lastActiveAt: row.last_active_at, + deletedAt: row.deleted_at, + messageCount: Number(row.message_count || 0), + outboundCount: Number(row.outbound_count || 0), + createdAt: row.created_at + }; +} + +export function toDictionaryGroup(row: any): DictionaryGroupSummary { + return { + groupKey: row.group_key, + label: row.label, + description: row.description, + isSystem: toBool(row.is_system), + version: Number(row.version || 1), + updatedAt: row.updated_at + }; +} + +export function toDictionaryItem(row: any): DictionaryItemSummary { + return { + groupKey: row.group_key, + value: row.value, + label: row.label, + description: row.description, + sortOrder: Number(row.sort_order || 0), + enabled: toBool(row.enabled), + metadata: parseJson>(row.metadata_json, {}), + updatedAt: row.updated_at + }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/settings.ts b/apps/worker/src/infrastructure/persistence/d1/settings.ts new file mode 100644 index 0000000..b5197e5 --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/settings.ts @@ -0,0 +1,339 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import { + applyDictionaryItemUpdate, + type MailDomainSummary, + buildDictionaryCatalog, + findDefaultDictionaryGroup, + findDefaultDictionaryItem +} from "@wemail/shared"; +import type { AppStore, QuotaRecord, RuntimeSettingsRecord } from "../../../core/bindings"; +import { toDictionaryGroup, toDictionaryItem } from "./row-mappers"; +import { nowIso, parseAllowedRoles, parseJson, toBool } from "./shared"; + +type SettingsAggregate = Pick< + AppStore, + "settings" | "runtimeSettings" | "mailDomains" | "dictionaries" | "accountSettings" | "mailSettings" | "quotas" +>; + +export function createSettingsAggregate(db: D1Database): SettingsAggregate { + return { + settings: { + async getFeatureToggles(defaults) { + const result = await db + .prepare("SELECT key, value FROM system_settings WHERE key IN (?, ?, ?, ?)") + .bind("aiEnabled", "telegramEnabled", "outboundEnabled", "mailboxCreationEnabled") + .all(); + const map = new Map((result.results ?? []).map((row: any) => [row.key, parseJson(row.value, false)])); + return { + aiEnabled: map.get("aiEnabled") ?? defaults.aiEnabled, + telegramEnabled: map.get("telegramEnabled") ?? defaults.telegramEnabled, + outboundEnabled: map.get("outboundEnabled") ?? defaults.outboundEnabled, + mailboxCreationEnabled: map.get("mailboxCreationEnabled") ?? defaults.mailboxCreationEnabled + }; + }, + async saveFeatureToggles(toggles) { + for (const [key, value] of Object.entries(toggles)) { + await db + .prepare("INSERT OR REPLACE INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)") + .bind(key, JSON.stringify(value), nowIso()) + .run(); + } + return toggles; + } + }, + runtimeSettings: { + async get() { + const result = await db + .prepare( + "SELECT key, value, updated_at FROM system_settings WHERE key IN (?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + "runtime.mailbox.limit", + "runtime.message.retentionDays", + "runtime.outbound.dailyLimit", + "runtime.api.dailyLimit", + "runtime.attachments.maxBytes", + "runtime.attachments.maxTotalBytes", + "runtime.ai.fallbackLimit" + ) + .all(); + + const rows = result.results ?? []; + if (rows.length === 0) return null; + const map = new Map(rows.map((row: any) => [row.key, row.value as string])); + const updatedAt = rows + .map((row: any) => row.updated_at as string | null | undefined) + .filter((value): value is string => Boolean(value)) + .sort() + .at(-1) ?? nowIso(); + + return { + mailboxLimit: map.get("runtime.mailbox.limit") ?? "", + messageRetentionDays: map.get("runtime.message.retentionDays") ?? "", + outboundDailyLimit: map.get("runtime.outbound.dailyLimit") ?? "", + apiDailyLimit: map.get("runtime.api.dailyLimit") ?? "", + maxAttachmentBytes: map.get("runtime.attachments.maxBytes") ?? "", + maxTotalAttachmentBytes: map.get("runtime.attachments.maxTotalBytes") ?? "", + aiFallbackLimit: map.get("runtime.ai.fallbackLimit") ?? "", + updatedAt + } satisfies RuntimeSettingsRecord; + }, + async save(record) { + const updatedAt = nowIso(); + const entries = [ + ["runtime.mailbox.limit", record.mailboxLimit], + ["runtime.message.retentionDays", record.messageRetentionDays], + ["runtime.outbound.dailyLimit", record.outboundDailyLimit], + ["runtime.api.dailyLimit", record.apiDailyLimit], + ["runtime.attachments.maxBytes", record.maxAttachmentBytes], + ["runtime.attachments.maxTotalBytes", record.maxTotalAttachmentBytes], + ["runtime.ai.fallbackLimit", record.aiFallbackLimit] + ] as const; + + for (const [key, value] of entries) { + await db + .prepare("INSERT OR REPLACE INTO system_settings (key, value, updated_at) VALUES (?, ?, ?)") + .bind(key, value, updatedAt) + .run(); + } + + return { + ...record, + updatedAt + }; + } + }, + mailDomains: { + async list(defaults) { + const result = await db + .prepare("SELECT domain, allowed_roles_json FROM mail_domains ORDER BY sort_order ASC, created_at ASC") + .all(); + const rows = result.results ?? []; + if (rows.length === 0) return defaults; + return rows.map((row: any): MailDomainSummary => ({ + domain: row.domain, + allowedRoles: parseAllowedRoles(row.allowed_roles_json) + })); + }, + async saveAll(domains) { + await db.prepare("DELETE FROM mail_domains").run(); + const now = nowIso(); + for (const [index, domain] of domains.entries()) { + await db + .prepare( + "INSERT INTO mail_domains (id, domain, allowed_roles_json, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(crypto.randomUUID(), domain.domain, JSON.stringify(domain.allowedRoles), index, now, now) + .run(); + } + return domains; + } + }, + dictionaries: { + async listGroups(groupKeys, options) { + const [groupResult, itemResult] = await Promise.all([ + db.prepare("SELECT * FROM dictionary_groups").all(), + db.prepare("SELECT * FROM dictionary_items").all() + ]); + + return buildDictionaryCatalog({ + groupKeys, + groups: (groupResult.results ?? []).map(toDictionaryGroup), + includeDisabled: options?.includeDisabled, + items: (itemResult.results ?? []).map(toDictionaryItem) + }); + }, + async updateItem(groupKey, value, input) { + const existingRow = await db + .prepare("SELECT * FROM dictionary_items WHERE group_key = ? AND value = ?") + .bind(groupKey, value) + .first(); + const existing = existingRow ? toDictionaryItem(existingRow) : findDefaultDictionaryItem(groupKey, value); + if (!existing) return null; + + const group = findDefaultDictionaryGroup(groupKey); + if (group) { + await db + .prepare( + "INSERT OR IGNORE INTO dictionary_groups (group_key, label, description, is_system, version, updated_at) VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(group.groupKey, group.label, group.description, group.isSystem ? 1 : 0, group.version, nowIso()) + .run(); + } + + const next = applyDictionaryItemUpdate(existing, input); + const updatedAt = nowIso(); + await db + .prepare( + "INSERT OR REPLACE INTO dictionary_items (id, group_key, value, label, description, sort_order, enabled, metadata_json, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + existingRow?.id ?? crypto.randomUUID(), + groupKey, + value, + next.label, + next.description, + next.sortOrder, + next.enabled ? 1 : 0, + JSON.stringify(next.metadata), + updatedAt + ) + .run(); + return { ...next, updatedAt }; + } + }, + accountSettings: { + async get() { + const row = await db.prepare("SELECT * FROM account_settings WHERE id = ?").bind("account_settings").first(); + return row + ? { + id: row.id, + creationJson: row.creation_json, + lifecycleJson: row.lifecycle_json, + protectionJson: row.protection_json, + updatedAt: row.updated_at + } + : null; + }, + async save(record) { + const updatedAt = nowIso(); + await db + .prepare( + "INSERT OR REPLACE INTO account_settings (id, creation_json, lifecycle_json, protection_json, updated_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind("account_settings", record.creationJson, record.lifecycleJson, record.protectionJson, updatedAt) + .run(); + return { id: "account_settings", updatedAt, ...record }; + } + }, + mailSettings: { + async get() { + const row = await db.prepare("SELECT * FROM mail_settings WHERE id = ?").bind("mail_settings").first(); + return row + ? { + id: row.id, + senderRulesJson: row.sender_rules_json, + routingJson: row.routing_json, + workspaceDefaultsJson: row.workspace_defaults_json, + updatedAt: row.updated_at + } + : null; + }, + async save(record) { + const updatedAt = nowIso(); + await db + .prepare( + "INSERT OR REPLACE INTO mail_settings (id, sender_rules_json, routing_json, workspace_defaults_json, updated_at) VALUES (?, ?, ?, ?, ?)" + ) + .bind("mail_settings", record.senderRulesJson, record.routingJson, record.workspaceDefaultsJson, updatedAt) + .run(); + return { id: "mail_settings", updatedAt, ...record }; + } + }, + quotas: { + async getByUserId(userId, fallbackLimit, fallbackApiDailyLimit) { + const row = await db.prepare("SELECT * FROM user_send_quotas WHERE user_id = ?").bind(userId).first(); + if (!row) { + const next: QuotaRecord = { + userId, + apiDailyLimit: fallbackApiDailyLimit, + apiCallsToday: 0, + dailyLimit: fallbackLimit, + sendsToday: 0, + disabled: false, + updatedAt: nowIso() + }; + await db + .prepare( + "INSERT INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(userId, next.dailyLimit, next.sendsToday, next.apiDailyLimit, next.apiCallsToday, 0, next.updatedAt) + .run(); + return next; + } + return { + userId: row.user_id, + apiDailyLimit: Number(row.api_daily_limit ?? fallbackApiDailyLimit), + apiCallsToday: Number(row.api_calls_today ?? 0), + dailyLimit: Number(row.daily_limit), + sendsToday: Number(row.sends_today), + disabled: toBool(row.disabled), + updatedAt: row.updated_at + }; + }, + async consumeApiCall(userId, fallbackLimit, fallbackApiDailyLimit) { + const now = nowIso(); + const today = now.slice(0, 10); + await db + .prepare( + "INSERT OR IGNORE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, 0, ?, 0, 0, ?)" + ) + .bind(userId, fallbackLimit, fallbackApiDailyLimit, now) + .run(); + + const result = await db + .prepare( + ` + UPDATE user_send_quotas + SET + sends_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE sends_today END, + api_calls_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 1 ELSE api_calls_today + 1 END, + updated_at = ? + WHERE user_id = ? + AND (CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE api_calls_today END) < api_daily_limit + ` + ) + .bind(today, today, now, userId, today) + .run(); + + if (Number(result.meta.changes ?? 0) === 0) return null; + return this.getByUserId(userId, fallbackLimit, fallbackApiDailyLimit); + }, + async consumeOutboundSend(userId, fallbackLimit, fallbackApiDailyLimit) { + const now = nowIso(); + const today = now.slice(0, 10); + await db + .prepare( + "INSERT OR IGNORE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, 0, ?, 0, 0, ?)" + ) + .bind(userId, fallbackLimit, fallbackApiDailyLimit, now) + .run(); + + const result = await db + .prepare( + ` + UPDATE user_send_quotas + SET + sends_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 1 ELSE sends_today + 1 END, + api_calls_today = CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE api_calls_today END, + updated_at = ? + WHERE user_id = ? + AND disabled = 0 + AND (CASE WHEN substr(updated_at, 1, 10) != ? THEN 0 ELSE sends_today END) < daily_limit + ` + ) + .bind(today, today, now, userId, today) + .run(); + + if (Number(result.meta.changes ?? 0) === 0) return null; + return this.getByUserId(userId, fallbackLimit, fallbackApiDailyLimit); + }, + async save(quota) { + await db + .prepare( + "INSERT OR REPLACE INTO user_send_quotas (user_id, daily_limit, sends_today, api_daily_limit, api_calls_today, disabled, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + quota.userId, + quota.dailyLimit, + quota.sendsToday, + quota.apiDailyLimit, + quota.apiCallsToday, + quota.disabled ? 1 : 0, + quota.updatedAt + ) + .run(); + } + } + }; +} diff --git a/apps/worker/src/infrastructure/persistence/d1/shared.ts b/apps/worker/src/infrastructure/persistence/d1/shared.ts new file mode 100644 index 0000000..6e6be3f --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/shared.ts @@ -0,0 +1,56 @@ +import { API_KEY_SCOPE_DEFINITIONS, DEFAULT_API_KEY_SCOPES, type ApiKeyScope } from "@wemail/shared"; +import type { MailboxDetailListQuery } from "../../../core/bindings"; + +export function nowIso() { + return new Date().toISOString(); +} + +export const DAY_MS = 24 * 60 * 60 * 1000; + +export function getSafePage(value: number) { + return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 1; +} + +export function getSafePageSize(value: number) { + if (!Number.isFinite(value) || value < 1) return 10; + return Math.min(Math.trunc(value), 500); +} + +export function getActiveRangeDays(value: MailboxDetailListQuery["activeRange"]) { + switch (value) { + case "7d": + return 7; + case "30d": + return 30; + case "90d": + return 90; + default: + return null; + } +} + +export function getInactiveDays(value: number | undefined) { + return Number.isFinite(value) && value && value > 0 ? Math.trunc(value) : 30; +} + +export function toBool(value: unknown) { + return value === 1 || value === "1" || value === true; +} + +export function parseJson(value: string | null | undefined, fallback: T) { + if (!value) return fallback; + return JSON.parse(value) as T; +} + +export function parseAllowedRoles(value: string | null | undefined) { + const roles = parseJson(value, []); + return roles.filter((role): role is "admin" | "member" => role === "admin" || role === "member"); +} + +export function parseApiKeyScopes(value: string | null | undefined) { + const scopes = parseJson(value, [...DEFAULT_API_KEY_SCOPES]); + const normalized = scopes.filter((scope): scope is ApiKeyScope => typeof scope === "string" && apiKeyScopeIds.has(scope)); + return normalized.length > 0 ? normalized : [...DEFAULT_API_KEY_SCOPES]; +} + +const apiKeyScopeIds = new Set(API_KEY_SCOPE_DEFINITIONS.map((scope) => scope.id)); diff --git a/apps/worker/src/infrastructure/persistence/d1/users.ts b/apps/worker/src/infrastructure/persistence/d1/users.ts new file mode 100644 index 0000000..7693e90 --- /dev/null +++ b/apps/worker/src/infrastructure/persistence/d1/users.ts @@ -0,0 +1,432 @@ +import type { D1Database } from "@cloudflare/workers-types"; +import type { AppStore } from "../../../core/bindings"; +import { + toApiKeyRecord, + toInviteRecord, + toOAuthIdentityRecord, + toOAuthPendingLoginRecord, + toOAuthStateRecord, + toSessionRecord, + toUserPreferencesRecord, + toUserRecord +} from "./row-mappers"; +import { nowIso } from "./shared"; + +type UsersAggregate = Pick; + +export function createUsersAggregate(db: D1Database): UsersAggregate { + return { + users: { + async count() { + const result = await db.prepare("SELECT count(*) AS count FROM users").first<{ count: number }>(); + return result?.count ?? 0; + }, + async countActiveByRole(role) { + const statement = role + ? db.prepare("SELECT count(*) AS count FROM users WHERE status = 'active' AND role = ?").bind(role) + : db.prepare("SELECT count(*) AS count FROM users WHERE status = 'active'"); + const result = await statement.first<{ count: number }>(); + return result?.count ?? 0; + }, + async findByEmail(email) { + const row = await db.prepare("SELECT * FROM users WHERE email = ?").bind(email).first(); + return row ? toUserRecord(row) : null; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM users WHERE id = ?").bind(id).first(); + return row ? toUserRecord(row) : null; + }, + async create(input) { + const id = crypto.randomUUID(); + const createdAt = nowIso(); + await db + .prepare( + "INSERT INTO users (id, email, name, password_hash, role, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'active', ?, ?)" + ) + .bind(id, input.email, input.name, input.passwordHash, input.role, createdAt, createdAt) + .run(); + return { + id, + email: input.email, + name: input.name, + passwordHash: input.passwordHash, + role: input.role, + status: "active", + createdAt, + updatedAt: createdAt + }; + }, + async updateProfile(id, input) { + await db.prepare("UPDATE users SET name = ?, updated_at = ? WHERE id = ?").bind(input.name, nowIso(), id).run(); + return this.findById(id); + }, + async updateRole(id, role) { + await db.prepare("UPDATE users SET role = ?, updated_at = ? WHERE id = ?").bind(role, nowIso(), id).run(); + return this.findById(id); + }, + async updatePasswordHash(id, passwordHash) { + await db.prepare("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?").bind(passwordHash, nowIso(), id).run(); + return this.findById(id); + }, + async updateStatus(id, status) { + await db.prepare("UPDATE users SET status = ?, updated_at = ? WHERE id = ?").bind(status, nowIso(), id).run(); + return this.findById(id); + }, + async delete(id) { + const existing = await this.findById(id); + if (!existing) return false; + await db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM oauth_identities WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM user_send_quotas WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM api_keys WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM telegram_subscriptions WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM user_preferences WHERE user_id = ?").bind(id).run(); + await db.prepare("DELETE FROM users WHERE id = ?").bind(id).run(); + return true; + }, + async list(options) { + const whereParts: string[] = []; + const bindings: Array = []; + const normalizedSearch = options.search?.toLowerCase(); + + if (normalizedSearch) { + whereParts.push("(lower(email) LIKE ? OR lower(name) LIKE ?)"); + bindings.push(`%${normalizedSearch}%`, `%${normalizedSearch}%`); + } + if (options.role) { + whereParts.push("role = ?"); + bindings.push(options.role); + } + if (options.status) { + whereParts.push("status = ?"); + bindings.push(options.status); + } + + const whereSql = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : ""; + const totalRow = await db + .prepare(`SELECT count(*) AS count FROM users${whereSql}`) + .bind(...bindings) + .first<{ count: number }>(); + const result = await db + .prepare(`SELECT * FROM users${whereSql} ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`) + .bind(...bindings, options.pageSize, (options.page - 1) * options.pageSize) + .all(); + + return { + users: (result.results ?? []).map(toUserRecord), + total: totalRow?.count ?? 0, + page: options.page, + pageSize: options.pageSize + }; + }, + async summary() { + const row = await db + .prepare( + "SELECT count(*) AS total, sum(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active FROM users" + ) + .first<{ active: number | null; total: number }>(); + return { + active: row?.active ?? 0, + total: row?.total ?? 0 + }; + } + }, + userPreferences: { + async getByUserId(userId) { + const row = await db.prepare("SELECT * FROM user_preferences WHERE user_id = ?").bind(userId).first(); + return row ? toUserPreferencesRecord(row) : null; + }, + async save(record) { + const updatedAt = nowIso(); + await db + .prepare( + "INSERT OR REPLACE INTO user_preferences (user_id, bio, locale, timezone, date_format, landing_page, density, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + record.userId, + record.bio, + record.locale, + record.timezone, + record.dateFormat, + record.landingPage, + record.density, + updatedAt + ) + .run(); + return { + ...record, + updatedAt + }; + } + }, + sessions: { + async create(input) { + const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; + const createdAt = nowIso(); + await db + .prepare( + "INSERT INTO auth_sessions (id, user_id, user_agent, ip_address, expires_at, last_seen_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(id, input.userId, input.userAgent ?? null, input.ipAddress ?? null, input.expiresAt, createdAt, createdAt) + .run(); + return { + id, + userId: input.userId, + userAgent: input.userAgent ?? null, + ipAddress: input.ipAddress ?? null, + expiresAt: input.expiresAt, + lastSeenAt: createdAt, + createdAt + }; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM auth_sessions WHERE id = ?").bind(id).first(); + return row ? toSessionRecord(row) : null; + }, + async listByUser(userId) { + const result = await db + .prepare("SELECT * FROM auth_sessions WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, created_at DESC") + .bind(userId) + .all(); + return (result.results ?? []).map(toSessionRecord); + }, + async touch(id, input = {}) { + const lastSeenAt = nowIso(); + await db + .prepare( + ` + UPDATE auth_sessions + SET + last_seen_at = ?, + user_agent = COALESCE(?, user_agent), + ip_address = COALESCE(?, ip_address) + WHERE id = ? + ` + ) + .bind(lastSeenAt, input.userAgent ?? null, input.ipAddress ?? null, id) + .run(); + }, + async delete(id) { + await db.prepare("DELETE FROM auth_sessions WHERE id = ?").bind(id).run(); + }, + async deleteByUserId(userId) { + await db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").bind(userId).run(); + }, + async deleteByUserIdExcept(userId, keepSessionId) { + await db.prepare("DELETE FROM auth_sessions WHERE user_id = ? AND id != ?").bind(userId, keepSessionId).run(); + } + }, + oauthStates: { + async create(input) { + const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; + const createdAt = nowIso(); + await db + .prepare("INSERT INTO oauth_states (id, provider, redirect_to, expires_at, created_at) VALUES (?, ?, ?, ?, ?)") + .bind(id, input.provider, input.redirectTo, input.expiresAt, createdAt) + .run(); + return { id, provider: input.provider, redirectTo: input.redirectTo, expiresAt: input.expiresAt, createdAt }; + }, + async consume(id) { + const row = await db.prepare("SELECT * FROM oauth_states WHERE id = ?").bind(id).first(); + await db.prepare("DELETE FROM oauth_states WHERE id = ?").bind(id).run(); + if (!row) return null; + const record = toOAuthStateRecord(row); + return new Date(record.expiresAt) > new Date() ? record : null; + } + }, + oauthPendingLogins: { + async create(input) { + const id = `${crypto.randomUUID()}-${Math.random().toString(36).slice(2, 10)}`; + const createdAt = nowIso(); + await db + .prepare( + "INSERT INTO oauth_pending_logins (id, provider, provider_user_id, provider_email, provider_name, provider_login, redirect_to, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind( + id, + input.provider, + input.providerUserId, + input.providerEmail, + input.providerName, + input.providerLogin, + input.redirectTo, + input.expiresAt, + createdAt + ) + .run(); + return { id, ...input, createdAt }; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM oauth_pending_logins WHERE id = ?").bind(id).first(); + if (!row) return null; + const record = toOAuthPendingLoginRecord(row); + if (new Date(record.expiresAt) > new Date()) return record; + await db.prepare("DELETE FROM oauth_pending_logins WHERE id = ?").bind(id).run(); + return null; + }, + async consume(id) { + const row = await db.prepare("SELECT * FROM oauth_pending_logins WHERE id = ?").bind(id).first(); + await db.prepare("DELETE FROM oauth_pending_logins WHERE id = ?").bind(id).run(); + if (!row) return null; + const record = toOAuthPendingLoginRecord(row); + return new Date(record.expiresAt) > new Date() ? record : null; + } + }, + oauthIdentities: { + async findByProviderUser(provider, providerUserId) { + const row = await db + .prepare("SELECT * FROM oauth_identities WHERE provider = ? AND provider_user_id = ?") + .bind(provider, providerUserId) + .first(); + return row ? toOAuthIdentityRecord(row) : null; + }, + async upsert(input) { + const existing = await this.findByProviderUser(input.provider, input.providerUserId); + const updatedAt = nowIso(); + if (existing) { + await db + .prepare( + "UPDATE oauth_identities SET user_id = ?, provider_email = ?, provider_login = ?, updated_at = ? WHERE id = ?" + ) + .bind(input.userId, input.providerEmail, input.providerLogin, updatedAt, existing.id) + .run(); + return { ...existing, userId: input.userId, providerEmail: input.providerEmail, providerLogin: input.providerLogin, updatedAt }; + } + + const id = crypto.randomUUID(); + await db + .prepare( + "INSERT INTO oauth_identities (id, user_id, provider, provider_user_id, provider_email, provider_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ) + .bind(id, input.userId, input.provider, input.providerUserId, input.providerEmail, input.providerLogin, updatedAt, updatedAt) + .run(); + return { id, ...input, createdAt: updatedAt, updatedAt }; + }, + async deleteByUserId(userId) { + await db.prepare("DELETE FROM oauth_identities WHERE user_id = ?").bind(userId).run(); + } + }, + invites: { + async create(input) { + const id = crypto.randomUUID(); + const createdAt = nowIso(); + const targetRole = input.targetRole ?? "member"; + const maxRedemptions = input.maxRedemptions ?? 1; + await db + .prepare( + "INSERT INTO user_invites (id, code, created_by_user_id, redeemed_by_user_id, redeemed_at, disabled_at, expires_at, target_role, max_redemptions, redemption_count, created_at) VALUES (?, ?, ?, NULL, NULL, NULL, ?, ?, ?, 0, ?)" + ) + .bind(id, input.code, input.createdByUserId, input.expiresAt ?? null, targetRole, maxRedemptions, createdAt) + .run(); + return { + id, + code: input.code, + createdByUserId: input.createdByUserId, + redeemedByUserId: null, + redeemedAt: null, + disabledAt: null, + expiresAt: input.expiresAt ?? null, + targetRole, + maxRedemptions, + redemptionCount: 0, + createdAt + }; + }, + async findByCode(code) { + const row = await db.prepare("SELECT * FROM user_invites WHERE code = ?").bind(code).first(); + return row ? toInviteRecord(row) : null; + }, + async findById(id) { + const row = await db.prepare("SELECT * FROM user_invites WHERE id = ?").bind(id).first(); + return row ? toInviteRecord(row) : null; + }, + async redeem(code, userId) { + const redeemedAt = nowIso(); + await db + .prepare( + "UPDATE user_invites SET redeemed_by_user_id = ?, redeemed_at = ?, redemption_count = redemption_count + 1 WHERE code = ? AND redemption_count < max_redemptions" + ) + .bind(userId, redeemedAt, code) + .run(); + return (await this.findByCode(code))!; + }, + async list() { + const result = await db.prepare("SELECT * FROM user_invites ORDER BY created_at DESC").all(); + return (result.results ?? []).map(toInviteRecord); + }, + async listPage(options) { + const totalRow = await db.prepare("SELECT count(*) AS count FROM user_invites").first<{ count: number }>(); + const availableRow = await db + .prepare( + "SELECT count(*) AS count FROM user_invites WHERE redemption_count < max_redemptions AND disabled_at IS NULL AND (expires_at IS NULL OR expires_at > ?)" + ) + .bind(nowIso()) + .first<{ count: number }>(); + const result = await db + .prepare( + `SELECT user_invites.*, users.name AS redeemed_by_user_name, users.email AS redeemed_by_user_email + FROM user_invites + LEFT JOIN users ON users.id = user_invites.redeemed_by_user_id + ORDER BY user_invites.created_at DESC + LIMIT ? OFFSET ?` + ) + .bind(options.pageSize, (options.page - 1) * options.pageSize) + .all(); + + return { + available: availableRow?.count ?? 0, + invites: (result.results ?? []).map(toInviteRecord), + page: options.page, + pageSize: options.pageSize, + total: totalRow?.count ?? 0 + }; + }, + async disable(id) { + await db.prepare("UPDATE user_invites SET disabled_at = ? WHERE id = ?").bind(nowIso(), id).run(); + } + }, + apiKeys: { + async create(input) { + const record = { + id: crypto.randomUUID(), + userId: input.userId, + label: input.label, + prefix: input.prefix, + scopes: input.scopes, + keyHash: input.keyHash, + createdAt: nowIso(), + lastUsedAt: null, + revokedAt: null + }; + await db + .prepare( + "INSERT INTO api_keys (id, user_id, label, prefix, scopes_json, key_hash, created_at, last_used_at, revoked_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)" + ) + .bind(record.id, record.userId, record.label, record.prefix, JSON.stringify(record.scopes), record.keyHash, record.createdAt) + .run(); + return record; + }, + async listByUser(userId) { + const result = await db.prepare("SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at DESC").bind(userId).all(); + return (result.results ?? []).map(toApiKeyRecord); + }, + async listAll() { + const result = await db.prepare("SELECT * FROM api_keys ORDER BY created_at DESC, id DESC").all(); + return (result.results ?? []).map(toApiKeyRecord); + }, + async findActiveByHash(hash) { + const row = await db + .prepare("SELECT * FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL") + .bind(hash) + .first(); + return row ? toApiKeyRecord(row) : null; + }, + async touch(id) { + await db.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?").bind(nowIso(), id).run(); + }, + async revoke(id, userId) { + await db.prepare("UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ?").bind(nowIso(), id, userId).run(); + } + } + }; +} diff --git a/apps/worker/src/infrastructure/persistence/in-memory.ts b/apps/worker/src/infrastructure/persistence/in-memory.ts index 9680b2e..443ff07 100644 --- a/apps/worker/src/infrastructure/persistence/in-memory.ts +++ b/apps/worker/src/infrastructure/persistence/in-memory.ts @@ -774,8 +774,12 @@ export function createInMemoryStore(): AppStore { async findById(id) { return clone(messages.get(id) ?? null); }, - async listExpired(beforeIso) { - return clone(Array.from(messages.values()).filter((entry) => entry.expiresAt <= beforeIso)); + async listExpired(beforeIso, options) { + const limit = options?.limit; + const expired = Array.from(messages.values()) + .filter((entry) => entry.expiresAt <= beforeIso) + .sort((a, b) => a.expiresAt.localeCompare(b.expiresAt) || a.id.localeCompare(b.id)); + return clone(limit !== undefined ? expired.slice(0, limit) : expired); }, async deleteMany(ids) { for (const id of ids) messages.delete(id); diff --git a/apps/worker/src/modules/announcements/routes.ts b/apps/worker/src/modules/announcements/routes.ts index 4a195a1..34b5e99 100644 --- a/apps/worker/src/modules/announcements/routes.ts +++ b/apps/worker/src/modules/announcements/routes.ts @@ -122,6 +122,14 @@ function isValidDateValue(value: string | null | undefined) { return !Number.isNaN(new Date(value).getTime()); } +// The JS visibility filters treat "" as "no date" (falsy), but a stored "" +// makes the SQL pushdown predicates disagree (e.g. end_at = '' fails the +// end_at >= now comparison and hides the row from the list). Normalize at the +// write boundary so the stored invariant is NULL-or-valid-ISO. +function normalizeDateValue(value: string | null | undefined) { + return value || null; +} + function hasValidDateWindow(startAt: string | null | undefined, endAt: string | null | undefined) { if (!startAt || !endAt) return true; const startTime = new Date(startAt).getTime(); @@ -241,8 +249,10 @@ export function registerAnnouncementsRoutes(app: Hono) { validateEnumValue(payload.audience, announcementAudiences, "audience") ?? validateEnumValue(payload.priority, announcementPriorities, "priority"); if (enumError) return jsonError(enumError, 400); - if (!isValidDateValue(payload.startAt) || !isValidDateValue(payload.endAt)) return jsonError("startAt or endAt is invalid", 400); - if (!hasValidDateWindow(payload.startAt, payload.endAt)) return jsonError("startAt must be before endAt", 400); + const startAt = normalizeDateValue(payload.startAt); + const endAt = normalizeDateValue(payload.endAt); + if (!isValidDateValue(startAt) || !isValidDateValue(endAt)) return jsonError("startAt or endAt is invalid", 400); + if (!hasValidDateWindow(startAt, endAt)) return jsonError("startAt must be before endAt", 400); const announcement = await c.get("store").announcements.create({ title, summary, @@ -254,8 +264,8 @@ export function registerAnnouncementsRoutes(app: Hono) { authorLabel: user.email, tagsJson: JSON.stringify(normalizeTags(payload.tags)), pinned: payload.pinned ?? false, - startAt: payload.startAt ?? null, - endAt: payload.endAt ?? null + startAt, + endAt }); await recordAudit(c.get("store"), "user", user.id, "announcement-create", { announcementId: announcement.id }); const [announcementPayload] = await buildAnnouncementJson(c, [announcement], user.id); @@ -314,8 +324,8 @@ export function registerAnnouncementsRoutes(app: Hono) { if ("priority" in payload && payload.priority) update.priority = payload.priority; if ("tags" in payload) update.tagsJson = JSON.stringify(normalizeTags(payload.tags)); if ("pinned" in payload) update.pinned = payload.pinned ?? false; - if ("startAt" in payload) update.startAt = payload.startAt ?? null; - if ("endAt" in payload) update.endAt = payload.endAt ?? null; + if ("startAt" in payload) update.startAt = normalizeDateValue(payload.startAt); + if ("endAt" in payload) update.endAt = normalizeDateValue(payload.endAt); const nextStartAt = "startAt" in update ? update.startAt : existing.startAt; const nextEndAt = "endAt" in update ? update.endAt : existing.endAt; if (!isValidDateValue(nextStartAt) || !isValidDateValue(nextEndAt)) return jsonError("startAt or endAt is invalid", 400); diff --git a/apps/worker/tests/announcements-sql.test.ts b/apps/worker/tests/announcements-sql.test.ts new file mode 100644 index 0000000..6c033e1 --- /dev/null +++ b/apps/worker/tests/announcements-sql.test.ts @@ -0,0 +1,311 @@ +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it } from "vitest"; + +import type { AnnouncementListOptions, AnnouncementVisibilityOptions } from "../src/core/bindings"; +import { createD1Store } from "../src/infrastructure/persistence/d1"; +import { + announcementStatuses, + filterAnnouncements, + getAnnouncementSummary, + getFeaturedAnnouncements, + paginateAnnouncements +} from "../src/shared/announcements"; + +// The D1 announcement methods push filtering into SQL. This suite proves the +// SQL predicates stay equivalent to the JS helpers in shared/announcements.ts +// by running the real store statements through node:sqlite and diffing results +// against the pure functions over the same rows. + +type AnnouncementRow = { + id: string; + title: string; + summary: string; + type: string; + status: string; + audience: string; + priority: string; + author_user_id: string | null; + author_label: string; + tags_json: string; + pinned: number; + start_at: string | null; + end_at: string | null; + published_at: string; + updated_at: string; +}; + +// Seed dates are anchored to Date.now() so every case stays meaningful +// forever: fixed absolute dates would drift past the predicate edges (7d +// cutoff, upcoming window) and the suite would keep passing vacuously. +const NOW_MS = Date.now(); +const SEED_DAY_MS = 24 * 60 * 60 * 1000; +const iso = (daysFromNow: number) => new Date(NOW_MS + daysFromNow * SEED_DAY_MS).toISOString(); + +function buildSeedRows(): AnnouncementRow[] { + const rows: AnnouncementRow[] = []; + const base = { + summary: "维护说明摘要", + type: "产品更新", + priority: "中", + author_user_id: null, + author_label: "系统", + tags_json: JSON.stringify(["发布", "release"]), + updated_at: iso(0) + }; + const counter = { value: 0 }; + const add = (overrides: Partial & { title: string }) => { + counter.value += 1; + rows.push({ + ...base, + id: `ann-${String(counter.value).padStart(3, "0")}`, + status: "已发布", + audience: "全部成员", + pinned: 0, + start_at: null, + end_at: null, + published_at: iso(-3), + ...overrides + }); + }; + + add({ title: "默认已发布" }); + add({ title: "置顶已发布", pinned: 1, published_at: iso(-10) }); + add({ title: "即将开始", start_at: iso(5), end_at: iso(12) }); + add({ title: "进行中窗口", start_at: iso(-3), end_at: iso(12) }); + add({ title: "已结束", end_at: iso(-5) }); + add({ title: "已归档", status: "已归档" }); + add({ title: "仅管理员", audience: "管理员" }); + add({ title: "仅普通成员", audience: "普通成员" }); + add({ title: "安全提醒类型", type: "安全提醒" }); + add({ title: "老公告三十天外", published_at: iso(-45) }); + add({ title: "含搜索词 ribbon 的公告" }); + add({ title: "标签含 release 的搜索", tags_json: JSON.stringify(["ribbon-tag"]) }); + // Keyword-parity rows: boundary spanning, adjacent tags, mixed-type tags, + // and an inverted window (unreachable via the API, pins predicate order). + add({ title: "Release", summary: "Notes 更新说明" }); + add({ title: "相邻标签公告", tags_json: JSON.stringify(["release", "notes"]) }); + add({ title: "混合类型标签公告", tags_json: '[123, "ok"]' }); + add({ title: "窗口倒置公告", start_at: iso(5), end_at: iso(-5) }); + return rows; +} + +const SEED_ROWS = buildSeedRows(); + +function createSqliteBackedStore() { + const sqlite = new DatabaseSync(":memory:"); + sqlite.exec(` + CREATE TABLE announcements ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + summary TEXT NOT NULL, + type TEXT NOT NULL, + status TEXT NOT NULL, + audience TEXT NOT NULL, + priority TEXT NOT NULL, + author_user_id TEXT, + author_label TEXT NOT NULL, + tags_json TEXT NOT NULL, + pinned INTEGER NOT NULL, + start_at TEXT, + end_at TEXT, + published_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + `); + const insert = sqlite.prepare( + "INSERT INTO announcements (id, title, summary, type, status, audience, priority, author_user_id, author_label, tags_json, pinned, start_at, end_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ); + for (const row of SEED_ROWS) { + insert.run( + row.id, + row.title, + row.summary, + row.type, + row.status, + row.audience, + row.priority, + row.author_user_id, + row.author_label, + row.tags_json, + row.pinned, + row.start_at, + row.end_at, + row.published_at, + row.updated_at + ); + } + + // D1 adapter: translate prepare/bind/first/all onto node:sqlite. + const db = { + prepare(sql: string) { + let bound: unknown[] = []; + const statement = { + bind(...values: unknown[]) { + bound = values; + return statement; + }, + async first() { + const row = sqlite.prepare(sql).get(...(bound as never[])) as T | undefined; + return row ?? null; + }, + async all() { + const results = sqlite.prepare(sql).all(...(bound as never[])) as unknown[]; + return { results }; + }, + async run() { + sqlite.prepare(sql).run(...(bound as never[])); + return { success: true }; + } + }; + return statement; + } + } as unknown as D1Database; + return { store: createD1Store(db), sqlite }; +} + +function toRecord(row: AnnouncementRow) { + return { + id: row.id, + title: row.title, + summary: row.summary, + type: row.type, + status: row.status, + audience: row.audience, + priority: row.priority, + authorUserId: row.author_user_id, + authorLabel: row.author_label, + tagsJson: row.tags_json, + pinned: row.pinned === 1, + startAt: row.start_at, + endAt: row.end_at, + publishedAt: row.published_at, + updatedAt: row.updated_at + }; +} + +const RECORDS = SEED_ROWS.map(toRecord); + +const VISIBILITY_CASES: Array<{ label: string; options: AnnouncementVisibilityOptions }> = [ + { label: "member visible", options: { scope: "visible", userRole: "member" } }, + { label: "admin visible", options: { scope: "visible", userRole: "admin" } }, + { label: "admin manage", options: { scope: "manage", userRole: "admin" } } +]; + +const LIST_CASES: Array<{ label: string; options: AnnouncementListOptions }> = [ + { label: "no filters", options: { page: 1, pageSize: 10, scope: "visible", userRole: "member" } }, + { label: "keyword", options: { page: 1, pageSize: 10, q: "ribbon", scope: "visible", userRole: "member" } }, + { + label: "keyword across title/summary boundary", + options: { page: 1, pageSize: 10, q: "release notes", scope: "visible", userRole: "member" } + }, + { label: "json punctuation keyword", options: { page: 1, pageSize: 10, q: ",", scope: "visible", userRole: "member" } }, + { + label: "manage scope inverted window upcoming", + options: { page: 1, pageSize: 10, status: "即将开始", scope: "manage", userRole: "admin" } + }, + { label: "type filter", options: { page: 1, pageSize: 10, type: "安全提醒", scope: "visible", userRole: "member" } }, + { label: "time 7d", options: { page: 1, pageSize: 10, time: "7d", scope: "visible", userRole: "member" } }, + { label: "time 30d", options: { page: 1, pageSize: 10, time: "30d", scope: "visible", userRole: "member" } }, + ...announcementStatuses.map((status) => ({ + label: `status ${status}`, + options: { page: 1, pageSize: 10, status, scope: "visible", userRole: "member" } as AnnouncementListOptions + })), + { label: "manage scope with status", options: { page: 1, pageSize: 10, status: "已归档", scope: "manage", userRole: "admin" } } +]; + +describe("D1 announcement SQL pushdown", () => { + for (const { label, options } of VISIBILITY_CASES) { + it(`listFeatured matches the JS helper for ${label}`, async () => { + const { store, sqlite } = createSqliteBackedStore(); + try { + const expected = getFeaturedAnnouncements(RECORDS, options); + const actual = await store.announcements.listFeatured(options); + expect(actual.map((entry) => entry.id)).toEqual(expected.map((entry) => entry.id)); + } finally { + sqlite.close(); + } + }); + + it(`summary matches the JS helper for ${label}`, async () => { + const { store, sqlite } = createSqliteBackedStore(); + try { + const expected = getAnnouncementSummary(RECORDS, options); + const actual = await store.announcements.summary(options); + expect(actual).toEqual(expected); + } finally { + sqlite.close(); + } + }); + } + + for (const { label, options } of LIST_CASES) { + it(`listPage matches the JS helper for ${label}`, async () => { + const { store, sqlite } = createSqliteBackedStore(); + try { + // filterAnnouncements returns the full filtered list; the expected page + // must apply the same pagination the SQL LIMIT/OFFSET implements. + const expected = filterAnnouncements(RECORDS, options); + const expectedPage = paginateAnnouncements(expected, options); + const actual = await store.announcements.listPage(options); + expect(actual.announcements.map((entry) => entry.id)).toEqual(expectedPage.map((entry) => entry.id)); + expect(actual.total).toBe(expected.length); + } finally { + sqlite.close(); + } + }); + } + + it("paginates without loading the whole table", async () => { + const { store, sqlite } = createSqliteBackedStore(); + try { + const pageOne = await store.announcements.listPage({ page: 1, pageSize: 3, scope: "visible", userRole: "member" }); + const pageTwo = await store.announcements.listPage({ page: 2, pageSize: 3, scope: "visible", userRole: "member" }); + expect(pageOne.announcements).toHaveLength(3); + expect(pageTwo.announcements.every((entry) => !pageOne.announcements.some((p) => p.id === entry.id))).toBe(true); + expect(pageOne.total).toBe(pageTwo.total); + } finally { + sqlite.close(); + } + }); + + it("matches keywords containing % literally, matching the JS helper", async () => { + // instr() has no wildcard semantics: a keyword like "50%" must match rows + // containing a literal "50%". Escaping (as a LIKE pattern would) would + // regress this to zero matches. + const { store, sqlite } = createSqliteBackedStore(); + try { + sqlite + .prepare( + "INSERT INTO announcements (id, title, summary, type, status, audience, priority, author_user_id, author_label, tags_json, pinned, start_at, end_at, published_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + ) + .run( + "ann-percent", + "折扣 50% 限时", + "维护说明摘要", + "产品更新", + "已发布", + "全部成员", + "中", + null, + "系统", + "[]", + 0, + null, + null, + iso(-3), + iso(0) + ); + const result = await store.announcements.listPage({ + page: 1, + pageSize: 10, + q: "50%", + scope: "visible", + userRole: "member" + }); + expect(result.total).toBe(1); + expect(result.announcements[0]?.id).toBe("ann-percent"); + } finally { + sqlite.close(); + } + }); +}); diff --git a/apps/worker/tests/d1-large-mailbox-scope.test.ts b/apps/worker/tests/d1-large-mailbox-scope.test.ts index 1f969ba..4fe29e8 100644 --- a/apps/worker/tests/d1-large-mailbox-scope.test.ts +++ b/apps/worker/tests/d1-large-mailbox-scope.test.ts @@ -62,4 +62,37 @@ describe("D1 large mailbox scopes", () => { expect(Math.max(...bindingCounts)).toBe(2); expect(preparedSql[0]).toContain("json_each(?)"); }); + + it("chunks IN clauses to respect the D1 bound-parameter limit", async () => { + // D1 caps bound parameters per query at 100. The cleanup cron and the + // batch-delete route can pass hundreds of ids at once; every prepared + // statement must stay at or below the limit or the whole query errors. + const bindingCounts: number[] = []; + const inClauseLengths: number[] = []; + const statement = { + bind: vi.fn((...bindings: unknown[]) => { + bindingCounts.push(bindings.length); + return statement; + }), + first: vi.fn(async () => null), + all: vi.fn(async () => ({ results: [] })), + run: vi.fn(async () => ({ success: true })) + }; + const db = { + prepare: vi.fn((sql: string) => { + inClauseLengths.push((sql.match(/\?/g) ?? []).length); + return statement; + }) + } as unknown as D1Database; + const store = createD1Store(db); + + const ids = Array.from({ length: 250 }, (_, index) => `message-${index}`); + await store.messages.deleteMany(ids); + await store.attachments.listByMessageIds(ids); + await store.attachments.deleteByMessageIds(ids); + + expect(bindingCounts.length).toBeGreaterThan(3); + expect(Math.max(...bindingCounts)).toBeLessThanOrEqual(100); + expect(Math.max(...inClauseLengths)).toBeLessThanOrEqual(100); + }); }); diff --git a/apps/worker/wrangler.toml b/apps/worker/wrangler.toml index 1ab1e39..e3a8323 100644 --- a/apps/worker/wrangler.toml +++ b/apps/worker/wrangler.toml @@ -63,6 +63,11 @@ name = "RATE_LIMITER" namespace_id = "1001" simple = { limit = 60, period = 60 } +[env.staging.triggers] +# Drives the `scheduled` handler (runCleanup) that expires messages and +# soft-deleted mailboxes. Hourly keeps retention granularity without stressing D1. +crons = ["0 * * * *"] + [env.production] workers_dev = true routes = [ @@ -99,3 +104,8 @@ name = "RATE_LIMITER" # Cloudflare Rate Limiting namespace IDs are account-unique integer strings. namespace_id = "1002" simple = { limit = 60, period = 60 } + +[env.production.triggers] +# Drives the `scheduled` handler (runCleanup) that expires messages and +# soft-deleted mailboxes. Hourly keeps retention granularity without stressing D1. +crons = ["0 * * * *"] diff --git a/docs/architecture/layered-architecture.md b/docs/architecture/layered-architecture.md index d665388..61c4a65 100644 --- a/docs/architecture/layered-architecture.md +++ b/docs/architecture/layered-architecture.md @@ -40,16 +40,18 @@ apps/web/src/ ```text apps/worker/src/ - app/ # 路由注册、请求流程、响应映射 + app/ # 请求中间件、用例编排、运行时入口(auth/CORS/限流在 create-app.ts) + modules/ # 按菜单域组织的 /api/* 路由注册(modules//routes.ts) core/ # 类型契约、绑定定义、上下文接口 infrastructure/ # D1、R2、外部服务集成 shared/ # 通用安全、邮件解析、纯辅助逻辑 ``` -依赖方向:`app -> core/infrastructure/shared` +依赖方向:`app -> modules -> core/infrastructure/shared` 说明: -- `app/` 负责 HTTP 入口和流程编排 +- `app/` 负责请求中间件、鉴权、限流与用例编排;旧 `app/routes/` 仅保留被模块复用的 DTO/request 解析 +- `modules/` 按左侧菜单域注册 HTTP 路由,由 `modules/register-modules.ts` 统一装配 - `core/` 定义接口与核心契约 - `infrastructure/` 承载持久化和外部依赖接入 - `shared/` 提供通用复用逻辑 diff --git a/docs/deploy-runbook.md b/docs/deploy-runbook.md index e21e4ff..23f656f 100644 --- a/docs/deploy-runbook.md +++ b/docs/deploy-runbook.md @@ -143,6 +143,7 @@ pnpm exec wrangler secret put TELEGRAM_WEBHOOK_SECRET --env production - `[[env.staging.d1_databases]]` / `[[env.production.d1_databases]]` - `[[env.staging.kv_namespaces]]` / `[[env.production.kv_namespaces]]` - `[[env.staging.r2_buckets]]` / `[[env.production.r2_buckets]]`(如启用附件存储) +- `[env.staging.triggers]` / `[env.production.triggers]` 的 `crons = ["0 * * * *"]`:驱动 `scheduled` 处理器执行 `runCleanup`(过期邮件、软删除邮箱清理)。漏配此项时清理任务不会运行,数据将按保留期无限累积 - AI、Rate Limiter、Queues 等其他启用的绑定 ## 4. 上线流程总览 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 92a0228..48605ac 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "WeMail 菜单化后端 API", - "version": "0.2.9", + "version": "0.2.10", "description": "WeMail 后端 API 已按管理后台左侧菜单分组。旧 /auth、/admin、/api/mailboxes、/api/messages、/api/outbound、/api/keys、/api/telegram 路径不再保留。" }, "servers": [ diff --git a/package.json b/package.json index 2fe0a78..e4d0a8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wemail", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "packageManager": "pnpm@10.18.2", diff --git a/packages/shared/package.json b/packages/shared/package.json index 60fd645..c2d88e3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@wemail/shared", - "version": "0.2.9", + "version": "0.2.10", "private": true, "type": "module", "exports": { diff --git a/packages/shared/src/version.ts b/packages/shared/src/version.ts index a1fea98..f58e079 100644 --- a/packages/shared/src/version.ts +++ b/packages/shared/src/version.ts @@ -1,2 +1,2 @@ -export const WEMAIL_VERSION = "0.2.9"; +export const WEMAIL_VERSION = "0.2.10"; export const WEMAIL_VERSION_LABEL = `v${WEMAIL_VERSION}`; diff --git a/scripts/check-migration-numbering.mjs b/scripts/check-migration-numbering.mjs new file mode 100644 index 0000000..03d94ff --- /dev/null +++ b/scripts/check-migration-numbering.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Validates that D1 migration files use unique sequential 0001-style numbers. +// Wrangler tracks applied migrations by filename, so a duplicate number is +// invisible to `d1 migrations list` today and only surfaces as ambiguity when +// someone later appends the "next" number. + +import { readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const migrationsDir = resolve(process.cwd(), "apps/worker/src/infrastructure/db/migrations"); + +// Historical conflicts that are already applied in remote D1 databases. +// Renaming them would desync `wrangler d1 migrations` state, so exactly these +// two files are grandfathered. Any other 0013 file (added or renamed) fails. +// Do not add new entries. +const GRANDFATHERED_DUPLICATES = new Set([ + "0013-oauth-login.sql", + "0013-telegram-chat-index.sql" +]); + +function main() { + const files = readdirSync(migrationsDir) + .filter((name) => /^\d{4}-.*\.sql$/.test(name)) + .sort(); + + const byNumber = new Map(); + for (const file of files) { + const number = file.slice(0, 4); + byNumber.set(number, [...(byNumber.get(number) ?? []), file]); + } + + const failures = []; + for (const [number, group] of byNumber) { + if (group.length < 2) continue; + + const isExactlyGrandfathered = + group.length === GRANDFATHERED_DUPLICATES.size && group.every((file) => GRANDFATHERED_DUPLICATES.has(file)); + if (isExactlyGrandfathered) { + console.warn(`[migration-numbering] duplicate ${number} is grandfathered: ${group.join(", ")}`); + continue; + } + failures.push(`${number}: ${group.join(", ")}`); + } + + if (failures.length > 0) { + console.error("Migration numbering check failed — duplicate numbers:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); + } + + console.log(`Migration numbering check passed (${files.length} files).`); +} + +main();