mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 00:01:42 +08:00
feat: add shares schema, service layer, and lifecycle integration (#308)
- Add `shares` and `share_recipients` tables to Drizzle schema with indices - Add migration 0010_shares.sql for shares/share_recipients tables - Add `server/lib/password.ts` extracting scrypt hash/verify from auth.ts to eliminate duplicate crypto params across services - Add `server/services/share.ts` implementing full CRUD + atomic counters: createShare, getShareByToken, incrementViews, incrementDownloadsAtomic (atomic SQL UPDATE), listSharesByCreator, revokeShare, cascadeDeleteByMatter - Add `shared/schemas/share.ts` Zod validation schemas - Export Share, ShareKind, ShareRecipient from shared/types - Extend `purge.ts` to cascade-delete shares on matter hard-delete - Add 38 integration tests and CF Workers atomic counter race tests Agent-Profile: https://agent-kanban.dev/agents/a6bb038c4226a87f Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
CREATE TABLE `shares` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`matter_id` text NOT NULL,
|
||||
`org_id` text NOT NULL,
|
||||
`creator_id` text NOT NULL,
|
||||
`password_hash` text,
|
||||
`expires_at` integer,
|
||||
`download_limit` integer,
|
||||
`views` integer DEFAULT 0 NOT NULL,
|
||||
`downloads` integer DEFAULT 0 NOT NULL,
|
||||
`status` text DEFAULT 'active' NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `shares_token_unique` ON `shares` (`token`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `shares_creator_status_created_idx` ON `shares` (`creator_id`,`status`,`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `share_recipients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`share_id` text NOT NULL,
|
||||
`recipient_user_id` text,
|
||||
`recipient_email` text,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `share_recipients_share_id_idx` ON `share_recipients` (`share_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `share_recipients_user_id_idx` ON `share_recipients` (`recipient_user_id`);
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1776200000000,
|
||||
"tag": "0009_matters_active_name_uniq",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "6",
|
||||
"when": 1745000000000,
|
||||
"tag": "0010_shares",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+10
-17
@@ -1,4 +1,3 @@
|
||||
import crypto from 'node:crypto'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import { admin, organization, username } from 'better-auth/plugins'
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
} from '../shared/oauth-providers'
|
||||
import * as authSchema from './db/auth-schema'
|
||||
import { orgQuotas, systemOptions } from './db/schema'
|
||||
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
|
||||
import type { Database } from './platform/interface'
|
||||
import { sendEmail } from './services/email'
|
||||
import { redeemInviteCode, validateInviteCode } from './services/invite'
|
||||
@@ -22,23 +22,16 @@ import { findPersonalOrg } from './services/org'
|
||||
|
||||
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
|
||||
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
|
||||
// node:crypto.scryptSync is native (OpenSSL) on both Node and Workers
|
||||
// (via nodejs_compat) and is counted as I/O rather than JS CPU time on CF.
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 }
|
||||
// We use node:crypto.scryptSync via server/lib/password.ts (native OpenSSL,
|
||||
// counted as I/O rather than JS CPU time on CF Workers).
|
||||
|
||||
async function hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS)
|
||||
return `${salt.toString('hex')}:${key.toString('hex')}`
|
||||
async function authHashPassword(password: string): Promise<string> {
|
||||
return hashPassword(password)
|
||||
}
|
||||
|
||||
async function verifyPassword({ hash, password }: { hash: string; password: string }): Promise<boolean> {
|
||||
const [saltHex, keyHex] = hash.split(':')
|
||||
if (!saltHex || !keyHex) {
|
||||
throw new Error('stored password hash is malformed: expected "<salt>:<key>"')
|
||||
}
|
||||
const key = crypto.scryptSync(password.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS)
|
||||
return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex'))
|
||||
async function authVerifyPassword({ hash, password }: { hash: string; password: string }): Promise<boolean> {
|
||||
if (!hash.includes(':')) throw new Error('stored password hash is malformed: expected "<salt>:<key>"')
|
||||
return verifyPasswordHash(hash, password)
|
||||
}
|
||||
|
||||
async function loadProviderConfig(db: Database, providerId: string) {
|
||||
@@ -145,8 +138,8 @@ export async function createAuth(db: Database, secret: string, baseURL?: string,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
password: {
|
||||
hash: hashPassword,
|
||||
verify: verifyPassword,
|
||||
hash: authHashPassword,
|
||||
verify: authVerifyPassword,
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
|
||||
+36
-1
@@ -1,4 +1,4 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
||||
|
||||
export const matters = sqliteTable('matters', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -79,3 +79,38 @@ export const activityEvents = sqliteTable('activity_events', {
|
||||
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),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
// node:crypto.scryptSync is native OpenSSL — safe on Cloudflare Workers (nodejs_compat)
|
||||
// and avoids the JS-CPU budget issue that the pure-JS @noble/hashes scrypt triggers.
|
||||
const SCRYPT_PARAMS = { N: 16384, r: 16, p: 1, maxmem: 128 * 16384 * 16 * 2 }
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16)
|
||||
const key = crypto.scryptSync(password.normalize('NFKC'), salt, 64, SCRYPT_PARAMS)
|
||||
return `${salt.toString('hex')}:${key.toString('hex')}`
|
||||
}
|
||||
|
||||
export function verifyPassword(hash: string, plaintext: string): boolean {
|
||||
const [saltHex, keyHex] = hash.split(':')
|
||||
if (!saltHex || !keyHex) return false
|
||||
const key = crypto.scryptSync(plaintext.normalize('NFKC'), Buffer.from(saltHex, 'hex'), 64, SCRYPT_PARAMS)
|
||||
return crypto.timingSafeEqual(key, Buffer.from(keyHex, 'hex'))
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { Storage as S3Storage } from '../../shared/types'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { decrementUsage, type Matter, purgeMatters } from './matter'
|
||||
import { S3Service } from './s3'
|
||||
import { cascadeDeleteByMatter } from './share'
|
||||
import { getStorage } from './storage'
|
||||
|
||||
const s3 = new S3Service()
|
||||
@@ -32,6 +33,10 @@ export async function purgeRecursively(db: Database, orgId: string, matters: Mat
|
||||
if (storage && keys.length > 0) await s3.deleteObjects(storage, keys)
|
||||
}
|
||||
|
||||
for (const m of matters) {
|
||||
await cascadeDeleteByMatter(db, m.id)
|
||||
}
|
||||
|
||||
await purgeMatters(
|
||||
db,
|
||||
orgId,
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
import { createCloudflarePlatform } from '../platform/cloudflare'
|
||||
import { cascadeDeleteByMatter, createShare, incrementDownloadsAtomic, revokeShare } from './share'
|
||||
|
||||
function buildDb() {
|
||||
return createCloudflarePlatform(env).db
|
||||
}
|
||||
|
||||
async function seedMatter(db: ReturnType<typeof buildDb>, orgId: string, dirtype = DirType.FILE) {
|
||||
const now = new Date()
|
||||
const matter = {
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
alias: nanoid(10),
|
||||
name: `cf-test-${nanoid(6)}`,
|
||||
type: dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
|
||||
size: 0,
|
||||
dirtype,
|
||||
parent: '',
|
||||
object: dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`,
|
||||
storageId: 'storage-1',
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(matters).values(matter)
|
||||
return matter
|
||||
}
|
||||
|
||||
// ─── Atomic counter race tests on D1 ─────────────────────────────────────────
|
||||
|
||||
describe('[CF] incrementDownloadsAtomic — race conditions on D1', () => {
|
||||
it('enforces download limit under 50 concurrent calls (limit=10)', async () => {
|
||||
const db = buildDb()
|
||||
const orgId = `org-${nanoid(6)}`
|
||||
const matter = await seedMatter(db, orgId)
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'cf-user-1',
|
||||
kind: 'landing',
|
||||
downloadLimit: 10,
|
||||
})
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id)))
|
||||
|
||||
const successCount = results.filter((r) => r.ok).length
|
||||
expect(successCount).toBe(10)
|
||||
})
|
||||
|
||||
it('returns ok=false for all calls when share is revoked', async () => {
|
||||
const db = buildDb()
|
||||
const orgId = `org-${nanoid(6)}`
|
||||
const matter = await seedMatter(db, orgId)
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'cf-user-2',
|
||||
kind: 'landing',
|
||||
})
|
||||
await revokeShare(db, share.id, 'cf-user-2')
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
|
||||
expect(results.every((r) => !r.ok)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns ok=false for all calls when share is expired', async () => {
|
||||
const db = buildDb()
|
||||
const orgId = `org-${nanoid(6)}`
|
||||
const matter = await seedMatter(db, orgId)
|
||||
|
||||
const pastDate = new Date(Date.now() - 5000)
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'cf-user-3',
|
||||
kind: 'landing',
|
||||
expiresAt: pastDate,
|
||||
})
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
|
||||
expect(results.every((r) => !r.ok)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── cascadeDeleteByMatter on D1 ─────────────────────────────────────────────
|
||||
|
||||
describe('[CF] cascadeDeleteByMatter on D1', () => {
|
||||
it('removes shares and recipients atomically', async () => {
|
||||
const db = buildDb()
|
||||
const orgId = `org-${nanoid(6)}`
|
||||
const matter = await seedMatter(db, orgId)
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'cf-cascade-user',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'cascade@example.com' }],
|
||||
})
|
||||
|
||||
await cascadeDeleteByMatter(db, matter.id)
|
||||
|
||||
// Share should be gone (token lookup returns null due to deletion)
|
||||
// We verify by checking the share lookup returns null
|
||||
const { getShareByToken } = await import('./share')
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,555 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
cascadeDeleteByMatter,
|
||||
createShare,
|
||||
getShareByToken,
|
||||
incrementDownloadsAtomic,
|
||||
incrementViews,
|
||||
isAccessibleByUser,
|
||||
listShareRecipientUserIds,
|
||||
listSharesByCreator,
|
||||
revokeShare,
|
||||
verifyPassword,
|
||||
} from './share.js'
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function seedMatter(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
opts: { orgId: string; storageId?: string; dirtype?: number; status?: string },
|
||||
) {
|
||||
const now = new Date()
|
||||
const matter = {
|
||||
id: nanoid(),
|
||||
orgId: opts.orgId,
|
||||
alias: nanoid(10),
|
||||
name: `test-${nanoid(6)}`,
|
||||
type: opts.dirtype !== DirType.FILE ? 'folder' : 'application/pdf',
|
||||
size: 0,
|
||||
dirtype: opts.dirtype ?? DirType.FILE,
|
||||
parent: '',
|
||||
object: opts.dirtype !== DirType.FILE ? '' : `objects/${nanoid()}`,
|
||||
storageId: opts.storageId ?? 'storage-1',
|
||||
status: opts.status ?? 'active',
|
||||
trashedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(matters).values(matter)
|
||||
return matter
|
||||
}
|
||||
|
||||
// ─── createShare ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createShare', () => {
|
||||
it('creates a landing share for a file with password and recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'user-1',
|
||||
kind: 'landing',
|
||||
password: 'secret123',
|
||||
recipients: [{ recipientEmail: 'alice@example.com' }],
|
||||
})
|
||||
|
||||
expect(share.id).toBeTruthy()
|
||||
expect(share.token).toHaveLength(10)
|
||||
expect(share.kind).toBe('landing')
|
||||
expect(share.status).toBe('active')
|
||||
expect(share.passwordHash).toBeTruthy()
|
||||
expect(share.passwordHash).not.toBe('secret123')
|
||||
})
|
||||
|
||||
it('creates a direct share for a file with no password and no recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'user-1',
|
||||
kind: 'direct',
|
||||
})
|
||||
|
||||
expect(share.kind).toBe('direct')
|
||||
expect(share.passwordHash).toBeNull()
|
||||
})
|
||||
|
||||
it('throws DIRECT_NO_PASSWORD when direct share has a password', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
await expect(
|
||||
createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'direct', password: 'oops' }),
|
||||
).rejects.toThrow('DIRECT_NO_PASSWORD')
|
||||
})
|
||||
|
||||
it('throws DIRECT_NO_RECIPIENTS when direct share has recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
await expect(
|
||||
createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'direct',
|
||||
recipients: [{ recipientEmail: 'bob@example.com' }],
|
||||
}),
|
||||
).rejects.toThrow('DIRECT_NO_RECIPIENTS')
|
||||
})
|
||||
|
||||
it('throws DIRECT_NO_FOLDER when direct share targets a folder', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const folder = await seedMatter(db, { orgId, dirtype: DirType.USER_FOLDER })
|
||||
|
||||
await expect(createShare(db, { matterId: folder.id, orgId, creatorId: 'u1', kind: 'direct' })).rejects.toThrow(
|
||||
'DIRECT_NO_FOLDER',
|
||||
)
|
||||
})
|
||||
|
||||
it('throws MATTER_NOT_FOUND when matter does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
await expect(
|
||||
createShare(db, { matterId: 'nonexistent', orgId: 'org-1', creatorId: 'u1', kind: 'landing' }),
|
||||
).rejects.toThrow('MATTER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('throws MATTER_NOT_FOUND when matter belongs to a different org', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const matter = await seedMatter(db, { orgId: 'org-a' })
|
||||
|
||||
await expect(
|
||||
createShare(db, { matterId: matter.id, orgId: 'org-b', creatorId: 'u1', kind: 'landing' }),
|
||||
).rejects.toThrow('MATTER_NOT_FOUND')
|
||||
})
|
||||
|
||||
it('sets downloadLimit and expiresAt when provided', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const expiresAt = new Date(Date.now() + 86400000)
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
downloadLimit: 5,
|
||||
expiresAt,
|
||||
})
|
||||
|
||||
expect(share.downloadLimit).toBe(5)
|
||||
expect(share.expiresAt).toEqual(expiresAt)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── getShareByToken ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('getShareByToken', () => {
|
||||
it('returns full data including matter and recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientUserId: 'user-42' }],
|
||||
})
|
||||
|
||||
const result = await getShareByToken(db, share.token)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.share.id).toBe(share.id)
|
||||
expect(result!.matter.id).toBe(matter.id)
|
||||
expect(result!.recipients).toHaveLength(1)
|
||||
expect(result!.recipients[0].recipientUserId).toBe('user-42')
|
||||
})
|
||||
|
||||
it('returns null when token does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
expect(await getShareByToken(db, 'nonexistent')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when share status is revoked', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
await revokeShare(db, share.id, 'u1')
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when underlying matter is trashed', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId, status: 'trashed' })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns data again after matter is restored from trash', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId, status: 'active' })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
// Trash it
|
||||
await db.update(matters).set({ status: 'trashed' }).where(eq(matters.id, matter.id))
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
|
||||
// Restore it
|
||||
await db.update(matters).set({ status: 'active' }).where(eq(matters.id, matter.id))
|
||||
const result = await getShareByToken(db, share.token)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result!.share.id).toBe(share.id)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── verifyPassword ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('verifyPassword', () => {
|
||||
it('returns true for the correct password', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
password: 'correct-password',
|
||||
})
|
||||
|
||||
expect(verifyPassword(share, 'correct-password')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for the wrong password', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
password: 'correct-password',
|
||||
})
|
||||
|
||||
expect(verifyPassword(share, 'wrong-password')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when share has no password hash', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
expect(verifyPassword(share, 'any-password')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── isAccessibleByUser ───────────────────────────────────────────────────────
|
||||
|
||||
describe('isAccessibleByUser', () => {
|
||||
it('returns true when userId is in recipients', () => {
|
||||
const now = new Date()
|
||||
const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-42', recipientEmail: null, createdAt: now }]
|
||||
expect(isAccessibleByUser(recipients, 'user-42')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when userId is not in recipients', () => {
|
||||
const now = new Date()
|
||||
const recipients = [{ id: '1', shareId: 's1', recipientUserId: 'user-99', recipientEmail: null, createdAt: now }]
|
||||
expect(isAccessibleByUser(recipients, 'user-42')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for empty recipients list', () => {
|
||||
expect(isAccessibleByUser([], 'user-42')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── incrementViews ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('incrementViews', () => {
|
||||
it('increments view count correctly', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
await incrementViews(db, share.id)
|
||||
await incrementViews(db, share.id)
|
||||
|
||||
const result = await getShareByToken(db, share.token)
|
||||
expect(result!.share.views).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── incrementDownloadsAtomic ─────────────────────────────────────────────────
|
||||
|
||||
describe('incrementDownloadsAtomic', () => {
|
||||
it('returns ok=true and incremented count on success', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
downloadLimit: 10,
|
||||
})
|
||||
|
||||
const result = await incrementDownloadsAtomic(db, share.id)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.downloads).toBe(1)
|
||||
})
|
||||
|
||||
it('enforces download limit — exactly limit calls succeed with concurrent calls', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
downloadLimit: 10,
|
||||
})
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 50 }, () => incrementDownloadsAtomic(db, share.id)))
|
||||
const successCount = results.filter((r) => r.ok).length
|
||||
expect(successCount).toBe(10)
|
||||
})
|
||||
|
||||
it('returns ok=false when share is revoked', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
await revokeShare(db, share.id, 'u1')
|
||||
|
||||
const result = await incrementDownloadsAtomic(db, share.id)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('returns ok=false when share is expired', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
expiresAt: pastDate,
|
||||
})
|
||||
|
||||
const result = await incrementDownloadsAtomic(db, share.id)
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
|
||||
it('allows unlimited downloads when downloadLimit is null', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => incrementDownloadsAtomic(db, share.id)))
|
||||
expect(results.every((r) => r.ok)).toBe(true)
|
||||
expect(results[4].downloads).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── listSharesByCreator ──────────────────────────────────────────────────────
|
||||
|
||||
describe('listSharesByCreator', () => {
|
||||
it('returns empty result when no shares exist for creator', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await listSharesByCreator(db, 'unknown-creator', { page: 1, pageSize: 20 })
|
||||
expect(result).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('returns shares with matterName and matterType joined', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
const result = await listSharesByCreator(db, 'u1', { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(1)
|
||||
expect(result.items[0].matterName).toBe(matter.name)
|
||||
expect(result.items[0].matterType).toBe(matter.type)
|
||||
})
|
||||
|
||||
it('paginates correctly', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const m = await seedMatter(db, { orgId })
|
||||
await createShare(db, { matterId: m.id, orgId, creatorId: 'paginator', kind: 'landing' })
|
||||
}
|
||||
|
||||
const page1 = await listSharesByCreator(db, 'paginator', { page: 1, pageSize: 3 })
|
||||
expect(page1.total).toBe(5)
|
||||
expect(page1.items).toHaveLength(3)
|
||||
|
||||
const page2 = await listSharesByCreator(db, 'paginator', { page: 2, pageSize: 3 })
|
||||
expect(page2.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns shares across multiple orgs for the same creator', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const m1 = await seedMatter(db, { orgId: 'org-x' })
|
||||
const m2 = await seedMatter(db, { orgId: 'org-y' })
|
||||
await createShare(db, { matterId: m1.id, orgId: 'org-x', creatorId: 'cross-org-user', kind: 'landing' })
|
||||
await createShare(db, { matterId: m2.id, orgId: 'org-y', creatorId: 'cross-org-user', kind: 'landing' })
|
||||
|
||||
const result = await listSharesByCreator(db, 'cross-org-user', { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(2)
|
||||
})
|
||||
|
||||
it('filters by status when provided', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const m1 = await seedMatter(db, { orgId })
|
||||
const m2 = await seedMatter(db, { orgId })
|
||||
await createShare(db, { matterId: m1.id, orgId, creatorId: 'u-filter', kind: 'landing' })
|
||||
const s2 = await createShare(db, { matterId: m2.id, orgId, creatorId: 'u-filter', kind: 'landing' })
|
||||
await revokeShare(db, s2.id, 'u-filter')
|
||||
|
||||
const active = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'active' })
|
||||
expect(active.total).toBe(1)
|
||||
|
||||
const revoked = await listSharesByCreator(db, 'u-filter', { page: 1, pageSize: 20, status: 'revoked' })
|
||||
expect(revoked.total).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── revokeShare ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('revokeShare', () => {
|
||||
it('flips share status to revoked', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
await revokeShare(db, share.id, 'u1')
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
})
|
||||
|
||||
it('throws when non-creator tries to revoke', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
|
||||
await expect(revokeShare(db, share.id, 'other-user')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('throws when share does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await expect(revokeShare(db, 'nonexistent', 'u1')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── listShareRecipientUserIds ────────────────────────────────────────────────
|
||||
|
||||
describe('listShareRecipientUserIds', () => {
|
||||
it('returns user IDs from recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientUserId: 'user-a' }, { recipientUserId: 'user-b' }, { recipientEmail: 'c@example.com' }],
|
||||
})
|
||||
|
||||
const ids = await listShareRecipientUserIds(db, share.id)
|
||||
expect(ids.sort()).toEqual(['user-a', 'user-b'].sort())
|
||||
})
|
||||
|
||||
it('returns empty array when no user recipients', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientEmail: 'email@example.com' }],
|
||||
})
|
||||
|
||||
const ids = await listShareRecipientUserIds(db, share.id)
|
||||
expect(ids).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ─── cascadeDeleteByMatter ────────────────────────────────────────────────────
|
||||
|
||||
describe('cascadeDeleteByMatter', () => {
|
||||
it('removes all shares and recipients for a matter', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const share = await createShare(db, {
|
||||
matterId: matter.id,
|
||||
orgId,
|
||||
creatorId: 'u1',
|
||||
kind: 'landing',
|
||||
recipients: [{ recipientUserId: 'user-x' }],
|
||||
})
|
||||
|
||||
await cascadeDeleteByMatter(db, matter.id)
|
||||
|
||||
expect(await getShareByToken(db, share.token)).toBeNull()
|
||||
const userIds = await listShareRecipientUserIds(db, share.id)
|
||||
expect(userIds).toEqual([])
|
||||
})
|
||||
|
||||
it('is a no-op when matter has no shares', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
|
||||
await expect(cascadeDeleteByMatter(db, matter.id)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('removes multiple shares for the same matter', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = nanoid()
|
||||
const matter = await seedMatter(db, { orgId })
|
||||
const s1 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u1', kind: 'landing' })
|
||||
const s2 = await createShare(db, { matterId: matter.id, orgId, creatorId: 'u2', kind: 'landing' })
|
||||
|
||||
await cascadeDeleteByMatter(db, matter.id)
|
||||
|
||||
expect(await getShareByToken(db, s1.token)).toBeNull()
|
||||
expect(await getShareByToken(db, s2.token)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { and, count, desc, eq, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import type { CreateShareInput } from '../../shared/schemas/share'
|
||||
import { matters, shareRecipients, shares } from '../db/schema'
|
||||
import { hashPassword, verifyPassword as verifyPasswordHash } from '../lib/password'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export type Share = typeof shares.$inferSelect
|
||||
export type ShareRecipient = typeof shareRecipients.$inferSelect
|
||||
export type ShareWithMatter = Share & { matterName: string; matterType: string }
|
||||
|
||||
export function verifyPassword(share: Share, plaintext: string): boolean {
|
||||
if (!share.passwordHash) return false
|
||||
return verifyPasswordHash(share.passwordHash, plaintext)
|
||||
}
|
||||
|
||||
export function isAccessibleByUser(recipients: ShareRecipient[], userId: string): boolean {
|
||||
return recipients.some((r) => r.recipientUserId === userId)
|
||||
}
|
||||
|
||||
export async function createShare(db: Database, input: CreateShareInput): Promise<Share> {
|
||||
if (input.kind === 'direct' && input.password) throw new Error('DIRECT_NO_PASSWORD')
|
||||
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
|
||||
throw new Error('DIRECT_NO_RECIPIENTS')
|
||||
|
||||
const matter = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.id, input.matterId), eq(matters.orgId, input.orgId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
|
||||
if (!matter) throw new Error('MATTER_NOT_FOUND')
|
||||
if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new Error('DIRECT_NO_FOLDER')
|
||||
|
||||
const now = new Date()
|
||||
const share: Share = {
|
||||
id: nanoid(),
|
||||
token: nanoid(10),
|
||||
kind: input.kind,
|
||||
matterId: input.matterId,
|
||||
orgId: input.orgId,
|
||||
creatorId: input.creatorId,
|
||||
passwordHash: input.password ? hashPassword(input.password) : null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
downloadLimit: input.downloadLimit ?? null,
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
await db.insert(shares).values(share)
|
||||
|
||||
if (input.recipients && input.recipients.length > 0) {
|
||||
const recipientRows: ShareRecipient[] = input.recipients.map((r) => ({
|
||||
id: nanoid(),
|
||||
shareId: share.id,
|
||||
recipientUserId: r.recipientUserId ?? null,
|
||||
recipientEmail: r.recipientEmail ?? null,
|
||||
createdAt: now,
|
||||
}))
|
||||
await db.insert(shareRecipients).values(recipientRows)
|
||||
}
|
||||
|
||||
return share
|
||||
}
|
||||
|
||||
export async function getShareByToken(
|
||||
db: Database,
|
||||
token: string,
|
||||
): Promise<{ share: Share; matter: typeof matters.$inferSelect; recipients: ShareRecipient[] } | null> {
|
||||
const rows = await db
|
||||
.select({ share: shares, matter: matters })
|
||||
.from(shares)
|
||||
.innerJoin(matters, eq(shares.matterId, matters.id))
|
||||
.where(eq(shares.token, token))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
if (row.share.status === 'revoked') return null
|
||||
if (row.matter.status === 'trashed') return null
|
||||
|
||||
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
|
||||
|
||||
return { share: row.share, matter: row.matter, recipients }
|
||||
}
|
||||
|
||||
export async function incrementViews(db: Database, shareId: string): Promise<void> {
|
||||
await db
|
||||
.update(shares)
|
||||
.set({ views: sql`${shares.views} + 1` })
|
||||
.where(eq(shares.id, shareId))
|
||||
}
|
||||
|
||||
export async function incrementDownloadsAtomic(
|
||||
db: Database,
|
||||
shareId: string,
|
||||
): Promise<{ ok: boolean; downloads: number }> {
|
||||
const nowSecs = Math.floor(Date.now() / 1000)
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ downloads: sql`${shares.downloads} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(shares.id, shareId),
|
||||
eq(shares.status, 'active'),
|
||||
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
|
||||
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
|
||||
),
|
||||
)
|
||||
.returning({ downloads: shares.downloads })
|
||||
|
||||
if (result.length === 1) {
|
||||
return { ok: true, downloads: result[0].downloads }
|
||||
}
|
||||
|
||||
const current = await db.select({ downloads: shares.downloads }).from(shares).where(eq(shares.id, shareId))
|
||||
if (!current[0]) throw new Error('SHARE_NOT_FOUND')
|
||||
return { ok: false, downloads: current[0].downloads }
|
||||
}
|
||||
|
||||
export async function listSharesByCreator(
|
||||
db: Database,
|
||||
creatorId: string,
|
||||
opts: { page: number; pageSize: number; status?: string },
|
||||
): Promise<{ items: ShareWithMatter[]; total: number }> {
|
||||
const conditions = [eq(shares.creatorId, creatorId)]
|
||||
if (opts.status) conditions.push(eq(shares.status, opts.status))
|
||||
const where = and(...conditions)
|
||||
|
||||
const [countRow] = await db.select({ count: count() }).from(shares).where(where)
|
||||
const total = countRow?.count ?? 0
|
||||
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
const rows = await db
|
||||
.select({ share: shares, matterName: matters.name, matterType: matters.type })
|
||||
.from(shares)
|
||||
.leftJoin(matters, eq(shares.matterId, matters.id))
|
||||
.where(where)
|
||||
.orderBy(desc(shares.createdAt))
|
||||
.limit(opts.pageSize)
|
||||
.offset(offset)
|
||||
|
||||
const items = rows.map(({ share, matterName, matterType }) => ({
|
||||
...share,
|
||||
matterName: matterName ?? '',
|
||||
matterType: matterType ?? '',
|
||||
}))
|
||||
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
export async function revokeShare(db: Database, shareId: string, creatorId: string): Promise<void> {
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ status: 'revoked' })
|
||||
.where(and(eq(shares.id, shareId), eq(shares.creatorId, creatorId)))
|
||||
.returning({ id: shares.id })
|
||||
|
||||
if (result.length === 0) throw new Error('SHARE_NOT_FOUND_OR_FORBIDDEN')
|
||||
}
|
||||
|
||||
export async function listShareRecipientUserIds(db: Database, shareId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ userId: shareRecipients.recipientUserId })
|
||||
.from(shareRecipients)
|
||||
.where(and(eq(shareRecipients.shareId, shareId), isNotNull(shareRecipients.recipientUserId)))
|
||||
|
||||
return rows.map((r) => r.userId as string)
|
||||
}
|
||||
|
||||
export async function cascadeDeleteByMatter(db: Database, matterId: string): Promise<void> {
|
||||
const shareRows = await db.select({ id: shares.id }).from(shares).where(eq(shares.matterId, matterId))
|
||||
if (shareRows.length === 0) return
|
||||
|
||||
const shareIds = shareRows.map((r) => r.id)
|
||||
await db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds))
|
||||
await db.delete(shares).where(inArray(shares.id, shareIds))
|
||||
}
|
||||
@@ -168,6 +168,31 @@ const APP_SCHEMA_SQL = `
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
|
||||
CREATE TABLE IF NOT EXISTS shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
matter_id TEXT NOT NULL,
|
||||
org_id TEXT NOT NULL,
|
||||
creator_id TEXT NOT NULL,
|
||||
password_hash TEXT,
|
||||
expires_at INTEGER,
|
||||
download_limit INTEGER,
|
||||
views INTEGER NOT NULL DEFAULT 0,
|
||||
downloads INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS shares_creator_status_created_idx ON shares(creator_id, status, created_at);
|
||||
CREATE TABLE IF NOT EXISTS share_recipients (
|
||||
id TEXT PRIMARY KEY,
|
||||
share_id TEXT NOT NULL,
|
||||
recipient_user_id TEXT,
|
||||
recipient_email TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS share_recipients_share_id_idx ON share_recipients(share_id);
|
||||
CREATE INDEX IF NOT EXISTS share_recipients_user_id_idx ON share_recipients(recipient_user_id);
|
||||
`
|
||||
|
||||
export async function createTestApp() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export type { CreateShareInput, ShareKind } from './share'
|
||||
export { createShareSchema, listSharesQuerySchema, shareKindSchema, shareRecipientSchema } from './share'
|
||||
export type { CreateStorageInput, UpdateStorageInput } from './storage'
|
||||
export { createStorageSchema, updateStorageSchema } from './storage'
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const shareKindSchema = z.enum(['landing', 'direct'])
|
||||
|
||||
export type ShareKind = z.infer<typeof shareKindSchema>
|
||||
|
||||
export const shareRecipientSchema = z.object({
|
||||
recipientUserId: z.string().optional(),
|
||||
recipientEmail: z.string().email().optional(),
|
||||
})
|
||||
|
||||
export const createShareSchema = z.object({
|
||||
matterId: z.string().min(1),
|
||||
orgId: z.string().min(1),
|
||||
creatorId: z.string().min(1),
|
||||
kind: shareKindSchema,
|
||||
password: z.string().optional(),
|
||||
expiresAt: z.date().optional(),
|
||||
downloadLimit: z.number().int().positive().optional(),
|
||||
recipients: z.array(shareRecipientSchema).optional(),
|
||||
})
|
||||
|
||||
export type CreateShareInput = z.infer<typeof createShareSchema>
|
||||
|
||||
export const listSharesQuerySchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
pageSize: z.coerce.number().int().positive().default(20),
|
||||
status: z.enum(['active', 'revoked']).optional(),
|
||||
})
|
||||
@@ -90,6 +90,34 @@ export interface PaginatedResponse<T> {
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
import type { ShareKind as _ShareKind } from '../schemas/share'
|
||||
|
||||
export type { ShareKind } from '../schemas/share'
|
||||
|
||||
export interface Share {
|
||||
id: string
|
||||
token: string
|
||||
kind: _ShareKind
|
||||
matterId: string
|
||||
orgId: string
|
||||
creatorId: string
|
||||
passwordHash: string | null
|
||||
expiresAt: Date | null
|
||||
downloadLimit: number | null
|
||||
views: number
|
||||
downloads: number
|
||||
status: 'active' | 'revoked'
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface ShareRecipient {
|
||||
id: string
|
||||
shareId: string
|
||||
recipientUserId: string | null
|
||||
recipientEmail: string | null
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export interface ActivityEvent {
|
||||
id: string
|
||||
orgId: string
|
||||
|
||||
Reference in New Issue
Block a user