mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
2c8e2cc837
* feat: add in-app notification system (站内信) — schema, service, API, Bell UI - Add `notifications` table to DB schema with userId/type/title/body/refType/refId/metadata/readAt/createdAt fields; two indexes for list & unread queries - Migration `0010_notifications.sql` created manually (drizzle-kit requires TTY) - Service layer: createNotification, listNotifications (paginated + unreadOnly filter), markAsRead (idempotent, owner-only), markAllAsRead, unreadCount - REST API at `/api/notifications`: list + unreadCount, GET unread-count, POST :id/read (204), POST read-all - Shared `Notification` type, `listNotificationsQuerySchema`, RPC client export - NotificationBell (badge, 30s polling), NotificationDropdown, NotificationItem components injected into AppSidebar footer - Bell badge capped at "9+"; unread items bold; click marks read + navigates via refType/refId - i18n: en + zh translations for all notification keys - 26 Node integration tests + 5 CF smoke tests; all 1884 + 26 tests pass Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f * test: add notification API wrapper tests and component logic tests; fix dead condition - Add tests for listNotifications, getUnreadCount, markNotificationRead, markAllNotificationsRead in api.test.ts - Add notification-bell.test.ts: badge label logic (0/5/"9+" cap) and polling interval - Add notification-dropdown.test.ts: mark-all-read visibility, empty state, query key - Add notification-item.test.ts: resolveHref (share token nav, malformed JSON), diffMinutes, isUnread, title style - Fix dead condition in markNotificationRead: simplify `!res.ok && res.status !== 204` → `!res.ok` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: trigger CI check run for test coverage fixes --------- Co-authored-by: Bob <aibob@mails.agent-kanban.dev> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
137 lines
5.1 KiB
TypeScript
137 lines
5.1 KiB
TypeScript
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
|
|
export const matters = sqliteTable('matters', {
|
|
id: text('id').primaryKey(),
|
|
orgId: text('org_id').notNull(),
|
|
alias: text('alias').notNull().unique(),
|
|
name: text('name').notNull(),
|
|
type: text('type').notNull(),
|
|
size: integer('size').default(0),
|
|
dirtype: integer('dirtype').default(0),
|
|
parent: text('parent').notNull().default(''),
|
|
object: text('object').notNull().default(''),
|
|
storageId: text('storage_id').notNull(),
|
|
status: text('status').notNull().default('draft'), // draft, active, trashed
|
|
trashedAt: integer('trashed_at'),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
})
|
|
|
|
export const storages = sqliteTable('storages', {
|
|
id: text('id').primaryKey(),
|
|
title: text('title').notNull(),
|
|
mode: text('mode').notNull(),
|
|
bucket: text('bucket').notNull(),
|
|
endpoint: text('endpoint').notNull(),
|
|
region: text('region').notNull().default('auto'),
|
|
accessKey: text('access_key').notNull(),
|
|
secretKey: text('secret_key').notNull(),
|
|
filePath: text('file_path').notNull().default(''),
|
|
customHost: text('custom_host').default(''),
|
|
capacity: integer('capacity').notNull().default(0),
|
|
used: integer('used').notNull().default(0),
|
|
status: text('status').notNull().default('active'),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
})
|
|
|
|
export const orgQuotas = sqliteTable('org_quotas', {
|
|
id: text('id').primaryKey(),
|
|
orgId: text('org_id').notNull(),
|
|
quota: integer('quota').notNull().default(0),
|
|
used: integer('used').notNull().default(0),
|
|
})
|
|
|
|
export const inviteCodes = sqliteTable('invite_codes', {
|
|
id: text('id').primaryKey(),
|
|
code: text('code').notNull().unique(),
|
|
createdBy: text('created_by').notNull(),
|
|
usedBy: text('used_by'),
|
|
usedAt: integer('used_at', { mode: 'timestamp' }),
|
|
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
})
|
|
|
|
export const systemOptions = sqliteTable('system_options', {
|
|
key: text('key').primaryKey(),
|
|
value: text('value').notNull().default(''),
|
|
public: integer('public', { mode: 'boolean' }).default(false),
|
|
})
|
|
|
|
export const teamInviteLinks = sqliteTable('team_invite_links', {
|
|
id: text('id').primaryKey(),
|
|
token: text('token').notNull().unique(),
|
|
organizationId: text('organization_id').notNull(),
|
|
role: text('role').notNull().default('member'),
|
|
inviterId: text('inviter_id').notNull(),
|
|
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }),
|
|
createdAt: integer('created_at', { mode: 'timestamp_ms' }).notNull(),
|
|
})
|
|
|
|
export const notifications = sqliteTable(
|
|
'notifications',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
userId: text('user_id').notNull(),
|
|
type: text('type').notNull(), // e.g. 'share_received'
|
|
title: text('title').notNull(),
|
|
body: text('body').notNull().default(''),
|
|
refType: text('ref_type'), // e.g. 'share'
|
|
refId: text('ref_id'),
|
|
metadata: text('metadata'), // JSON string for extra context
|
|
readAt: integer('read_at', { mode: 'timestamp' }),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
},
|
|
(t) => [
|
|
index('notifications_user_created_idx').on(t.userId, t.createdAt),
|
|
index('notifications_user_read_idx').on(t.userId, t.readAt),
|
|
],
|
|
)
|
|
|
|
export const activityEvents = sqliteTable('activity_events', {
|
|
id: text('id').primaryKey(),
|
|
orgId: text('org_id').notNull(),
|
|
userId: text('user_id').notNull(),
|
|
action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore'
|
|
targetType: text('target_type').notNull(), // 'file', 'folder'
|
|
targetId: text('target_id'),
|
|
targetName: text('target_name').notNull(),
|
|
metadata: text('metadata'), // JSON
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
})
|
|
|
|
export const shares = sqliteTable(
|
|
'shares',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
token: text('token').notNull().unique(),
|
|
kind: text('kind').notNull(), // 'landing' | 'direct'
|
|
matterId: text('matter_id').notNull(),
|
|
orgId: text('org_id').notNull(),
|
|
creatorId: text('creator_id').notNull(),
|
|
passwordHash: text('password_hash'),
|
|
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
|
downloadLimit: integer('download_limit'),
|
|
views: integer('views').notNull().default(0),
|
|
downloads: integer('downloads').notNull().default(0),
|
|
status: text('status').notNull().default('active'), // 'active' | 'revoked'
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
},
|
|
(t) => [index('shares_creator_status_created_idx').on(t.creatorId, t.status, t.createdAt)],
|
|
)
|
|
|
|
export const shareRecipients = sqliteTable(
|
|
'share_recipients',
|
|
{
|
|
id: text('id').primaryKey(),
|
|
shareId: text('share_id').notNull(),
|
|
recipientUserId: text('recipient_user_id'),
|
|
recipientEmail: text('recipient_email'),
|
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
},
|
|
(t) => [
|
|
index('share_recipients_share_id_idx').on(t.shareId),
|
|
index('share_recipients_user_id_idx').on(t.recipientUserId),
|
|
],
|
|
)
|