diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index bd687a1..40db362 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -475,7 +475,11 @@ export function apiRouter( if (typeof req.query['type'] === 'string') filter.type = req.query['type'] if (typeof req.query['status'] === 'string') filter.status = req.query['status'] if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId'] - res.json({ ok: true, documents: listDocuments(db, filter) }) + const page = Number(req.query['page']) + if (Number.isInteger(page) && page >= 1) filter.page = page + const pageSize = Number(req.query['pageSize']) + if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize + res.json({ ok: true, ...listDocuments(db, filter) }) }) r.get('/documents/:id', requireAuth, (req, res) => { const id = String(req.params['id'] ?? '') @@ -958,6 +962,23 @@ export function apiRouter( r.get('/settings/company', requireAuth, (_req, res) => { res.json({ ok: true, company: companySettings() }) }) + + // ---------- sharing defaults (owner) ---------- + r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => { + const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) + .get() as { value: string } | undefined + const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value) + res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days }) + }) + r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => { + const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays + if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) { + res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' }) + return + } + setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v)) + res.json({ ok: true, defaultExpiryDays: v }) + }) r.put('/settings/company', requireAuth, requireOwner, (req, res) => { const body = req.body as Record try { diff --git a/apps/hq/src/repos-shares.ts b/apps/hq/src/repos-shares.ts index b257905..7fb28de 100644 --- a/apps/hq/src/repos-shares.ts +++ b/apps/hq/src/repos-shares.ts @@ -71,13 +71,23 @@ export interface MintShareOpts { expiresDays?: number | null } +/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */ +function defaultExpiryDays(db: DB): number | null { + const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) + .get() as { value: string } | undefined + if (row === undefined) return 30 + if (row.value === 'never') return null + const n = Number(row.value) + return Number.isInteger(n) && n > 0 ? n : 30 +} + export function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Share { if (getDocument(db, documentId) === null) throw new Error('Document not found') const id = uuidv7() const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars const now = new Date() const createdAt = now.toISOString() - const expiresDays = opts.expiresDays === undefined ? 30 : opts.expiresDays + const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays const expiresAt = expiresDays === null ? null : new Date(now.getTime() + expiresDays * 86_400_000).toISOString() diff --git a/apps/hq/test/settings-sharing.test.ts b/apps/hq/test/settings-sharing.test.ts new file mode 100644 index 0000000..0a80e16 --- /dev/null +++ b/apps/hq/test/settings-sharing.test.ts @@ -0,0 +1,69 @@ +// apps/hq/test/settings-sharing.test.ts +import { describe, it, expect } from 'vitest' +import { openDb } from '../src/db' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft } from '../src/repos-documents' +import { mintShare } from '../src/repos-shares' + +function setup() { + const db = openDb(':memory:') + db.prepare(`INSERT INTO setting (key, value) VALUES ('company.state_code','32')`).run() + db.prepare(`INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`).run() + const c = createClient(db, 'u1', { name: 'Acme', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + return { db, c, m, doc } +} + +describe('mintShare default expiry (share.default_expiry_days setting)', () => { + it('no setting row: defaults to +30d', () => { + const { db, doc } = setup() + const s = mintShare(db, 'u1', doc.id) + expect(s.expiresAt).not.toBeNull() + const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 + expect(days).toBeGreaterThan(29) + expect(days).toBeLessThan(31) + }) + + it("setting '7': defaults to +7d", () => { + const { db, doc } = setup() + db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run() + const s = mintShare(db, 'u1', doc.id) + expect(s.expiresAt).not.toBeNull() + const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 + expect(days).toBeGreaterThan(6) + expect(days).toBeLessThan(8) + }) + + it("setting 'never': expiresAt is null", () => { + const { db, doc } = setup() + db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'never')`).run() + const s = mintShare(db, 'u1', doc.id) + expect(s.expiresAt).toBeNull() + }) + + it('explicit opts.expiresDays always wins over the setting (including explicit null)', () => { + const { db, doc } = setup() + db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', '7')`).run() + const s1 = mintShare(db, 'u1', doc.id, { expiresDays: 3 }) + expect(s1.expiresAt).not.toBeNull() + const days1 = (new Date(s1.expiresAt!).getTime() - Date.now()) / 86_400_000 + expect(days1).toBeGreaterThan(2) + expect(days1).toBeLessThan(4) + const s2 = mintShare(db, 'u1', doc.id, { expiresDays: null }) + expect(s2.expiresAt).toBeNull() + }) + + it("garbage setting 'abc': falls back to +30d", () => { + const { db, doc } = setup() + db.prepare(`INSERT INTO setting (key, value) VALUES ('share.default_expiry_days', 'abc')`).run() + const s = mintShare(db, 'u1', doc.id) + expect(s.expiresAt).not.toBeNull() + const days = (new Date(s.expiresAt!).getTime() - Date.now()) / 86_400_000 + expect(days).toBeGreaterThan(29) + expect(days).toBeLessThan(31) + }) +})