mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 08:16:58 +08:00
feat: add invite code system for registration gating (#281)
Admin can generate, list, and delete invite codes. Public endpoint validates codes before sign-up. Codes are 8-char uppercase alphanumeric with optional expiration. Redemption uses atomic UPDATE to prevent concurrent double-use. 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,11 @@
|
||||
CREATE TABLE `invite_codes` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text NOT NULL,
|
||||
`created_by` text NOT NULL,
|
||||
`used_by` text,
|
||||
`used_at` integer,
|
||||
`expires_at` integer,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `invite_codes_code_unique` ON `invite_codes` (`code`);
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1775100000000,
|
||||
"tag": "0004_username_plugin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1775110000000,
|
||||
"tag": "0005_invite_codes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Env } from './middleware/platform'
|
||||
import { platformMiddleware } from './middleware/platform'
|
||||
import type { Platform } from './platform/interface'
|
||||
import emailConfig from './routes/email-config'
|
||||
import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes'
|
||||
import objects from './routes/objects'
|
||||
import { adminQuotas, userQuotas } from './routes/quotas'
|
||||
import storages from './routes/storages'
|
||||
@@ -44,6 +45,8 @@ export function createApp(platform: Platform, auth: Auth) {
|
||||
app.route('/api/admin/storages', storages)
|
||||
app.route('/api/admin/users', users)
|
||||
app.route('/api/admin/email-config', emailConfig)
|
||||
app.route('/api/admin/invite-codes', adminInviteCodes)
|
||||
app.route('/api/invite-codes', publicInviteCodes)
|
||||
app.route('/api/admin/quotas', adminQuotas)
|
||||
app.route('/api/quotas', userQuotas)
|
||||
app.route('/api/system', system)
|
||||
@@ -64,3 +67,5 @@ export type AdminQuotasRoute = typeof adminQuotas
|
||||
export type UserQuotasRoute = typeof userQuotas
|
||||
export type SystemRoute = typeof system
|
||||
export type EmailConfigRoute = typeof emailConfig
|
||||
export type AdminInviteCodesRoute = typeof adminInviteCodes
|
||||
export type PublicInviteCodesRoute = typeof publicInviteCodes
|
||||
|
||||
@@ -42,6 +42,16 @@ export const orgQuotas = sqliteTable('org_quotas', {
|
||||
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(''),
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateInviteCodes, redeemInviteCode } from '../services/invite.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp } from '../test/setup.js'
|
||||
|
||||
// ─── Admin routes ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Admin Invite Codes API — auth guards', () => {
|
||||
it('GET / returns 401 without auth', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/admin/invite-codes')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('GET / returns 403 for a non-admin user', async () => {
|
||||
const { app } = createTestApp()
|
||||
await adminHeaders(app) // first user becomes admin
|
||||
const headers = await authedHeaders(app, 'regular@example.com')
|
||||
const res = await app.request('/api/admin/invite-codes', { headers })
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('POST / returns 401 without auth', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 1 }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('DELETE /:id returns 401 without auth', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/admin/invite-codes/someid', { method: 'DELETE' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Invite Codes API — GET /', () => {
|
||||
it('returns an empty list when no codes exist', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('returns created codes with correct total', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 3 }),
|
||||
})
|
||||
|
||||
const res = await app.request('/api/admin/invite-codes', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body.total).toBe(3)
|
||||
expect(body.items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('paginates with page and pageSize query params', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
|
||||
await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 5 }),
|
||||
})
|
||||
|
||||
const res = await app.request('/api/admin/invite-codes?page=2&pageSize=3', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { items: unknown[]; total: number }
|
||||
expect(body.total).toBe(5)
|
||||
expect(body.items).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Invite Codes API — POST /', () => {
|
||||
it('creates the requested number of codes and returns 201', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 4 }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { codes: unknown[] }
|
||||
expect(body.codes).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('creates codes with an expiry when expiresInDays is provided', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 1, expiresInDays: 7 }),
|
||||
})
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { codes: Array<{ expiresAt: string | null }> }
|
||||
expect(body.codes[0].expiresAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('returns 400 when count is missing', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when count exceeds maximum of 100', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 101 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when count is zero', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count: 0 }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Admin Invite Codes API — DELETE /:id', () => {
|
||||
it('deletes an unused code and returns deleted:true', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const [row] = await generateInviteCodes(db, 'admin-user', 1)
|
||||
|
||||
const res = await app.request(`/api/admin/invite-codes/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { id: string; deleted: boolean }
|
||||
expect(body.deleted).toBe(true)
|
||||
expect(body.id).toBe(row.id)
|
||||
})
|
||||
|
||||
it('returns 404 for a nonexistent code id', async () => {
|
||||
const { app } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const res = await app.request('/api/admin/invite-codes/nonexistent', {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
it('returns 400 when trying to delete an already-used code', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
const [row] = await generateInviteCodes(db, 'admin-user', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-123')
|
||||
|
||||
const res = await app.request(`/api/admin/invite-codes/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Public routes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('Public Invite Codes API — POST /validate', () => {
|
||||
it('returns valid:true for a valid unused code', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: row.code }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { valid: boolean }
|
||||
expect(body.valid).toBe(true)
|
||||
})
|
||||
|
||||
it('returns valid:false for a nonexistent code', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'NOSUCHCD' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { valid: boolean; error?: string }
|
||||
expect(body.valid).toBe(false)
|
||||
expect(body.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns valid:false for a used code', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-99')
|
||||
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: row.code }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { valid: boolean }
|
||||
expect(body.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns valid:false for an expired code', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const past = new Date(Date.now() - 1000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, past)
|
||||
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: row.code }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { valid: boolean }
|
||||
expect(body.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns 400 when code field is missing from request body', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when code is an empty string', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: '' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when code contains lowercase letters', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'abcd1234' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when code is fewer than 8 characters', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'ABC123' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('returns 400 when code is more than 8 characters', async () => {
|
||||
const { app } = createTestApp()
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: 'ABCD12345' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('is accessible without authentication', async () => {
|
||||
const { app, db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
|
||||
// No auth headers — should still work
|
||||
const res = await app.request('/api/invite-codes/validate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: row.code }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { deleteInviteCode, generateInviteCodes, listInviteCodes, validateInviteCode } from '../services/invite'
|
||||
|
||||
const generateSchema = z.object({
|
||||
count: z.number().int().min(1).max(100),
|
||||
expiresInDays: z.number().int().min(1).optional(),
|
||||
})
|
||||
|
||||
const validateSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(8)
|
||||
.regex(/^[0-9A-Z]{8}$/),
|
||||
})
|
||||
|
||||
const paginationSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
pageSize: z.coerce.number().int().min(1).max(100).default(20),
|
||||
})
|
||||
|
||||
export const adminInviteCodes = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
.get('/', zValidator('query', paginationSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const { page, pageSize } = c.req.valid('query')
|
||||
const result = await listInviteCodes(db, page, pageSize)
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/', zValidator('json', generateSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')
|
||||
if (!userId) return c.json({ error: 'Unauthorized' }, 401)
|
||||
const { count, expiresInDays } = c.req.valid('json')
|
||||
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 86400000) : undefined
|
||||
const codes = await generateInviteCodes(db, userId, count, expiresAt)
|
||||
return c.json({ codes }, 201)
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const result = await deleteInviteCode(db, id)
|
||||
if (result === 'not_found') return c.json({ error: 'Invite code not found' }, 404)
|
||||
if (result === 'already_used') return c.json({ error: 'Cannot delete a used invite code' }, 400)
|
||||
return c.json({ id, deleted: true })
|
||||
})
|
||||
|
||||
export const publicInviteCodes = new Hono<Env>().post('/validate', zValidator('json', validateSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const { code } = c.req.valid('json')
|
||||
const result = await validateInviteCode(db, code)
|
||||
return c.json(result)
|
||||
})
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
deleteInviteCode,
|
||||
generateInviteCodes,
|
||||
listInviteCodes,
|
||||
redeemInviteCode,
|
||||
validateInviteCode,
|
||||
} from './invite.js'
|
||||
|
||||
describe('generateInviteCodes', () => {
|
||||
it('returns the requested number of codes', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 5)
|
||||
expect(codes).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('returns one code when count is 1', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 1)
|
||||
expect(codes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('generates unique codes for each entry', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 10)
|
||||
const uniqueCodes = new Set(codes.map((c) => c.code))
|
||||
expect(uniqueCodes.size).toBe(10)
|
||||
})
|
||||
|
||||
it('each code has an 8-character uppercase alphanumeric code field', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 3)
|
||||
for (const code of codes) {
|
||||
expect(code.code).toMatch(/^[0-9A-Z]{8}$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('sets createdBy to the provided admin user id on all codes', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-42', 3)
|
||||
for (const code of codes) {
|
||||
expect(code.createdBy).toBe('admin-42')
|
||||
}
|
||||
})
|
||||
|
||||
it('sets usedBy and usedAt to null on fresh codes', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
for (const code of codes) {
|
||||
expect(code.usedBy).toBeNull()
|
||||
expect(code.usedAt).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('sets expiresAt to null when not provided', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
for (const code of codes) {
|
||||
expect(code.expiresAt).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('propagates expiresAt to all generated codes', async () => {
|
||||
const { db } = createTestApp()
|
||||
const expiry = new Date(Date.now() + 86400000)
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 3, expiry)
|
||||
for (const code of codes) {
|
||||
expect(code.expiresAt).not.toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('persists codes to the database', async () => {
|
||||
const { db } = createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
for (const code of codes) {
|
||||
const result = await validateInviteCode(db, code.code)
|
||||
expect(result.valid).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateInviteCode', () => {
|
||||
it('returns valid:true for an unused, unexpired code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
expect(result).toEqual({ valid: true })
|
||||
})
|
||||
|
||||
it('returns valid:false with an error for a nonexistent code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const result = await validateInviteCode(db, 'NOSUCHCD')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns valid:false with an error for a used code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-99')
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns valid:false with an error for an expired code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns valid:true for a code that has not yet expired', async () => {
|
||||
const { db } = createTestApp()
|
||||
const futureDate = new Date(Date.now() + 86400000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, futureDate)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('redeemInviteCode', () => {
|
||||
it('returns ok when redeeming a valid unused code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await redeemInviteCode(db, row.code, 'user-55')
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('marks the code as used so it cannot be validated again', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
expect(check.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found for a nonexistent code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const result = await redeemInviteCode(db, 'NOSUCHCD', 'user-55')
|
||||
expect(result).toBe('not_found')
|
||||
})
|
||||
|
||||
it('returns already_used when redeeming a previously redeemed code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const result = await redeemInviteCode(db, row.code, 'user-99')
|
||||
expect(result).toBe('already_used')
|
||||
})
|
||||
|
||||
it('returns expired for an expired code', async () => {
|
||||
const { db } = createTestApp()
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
|
||||
const result = await redeemInviteCode(db, row.code, 'user-55')
|
||||
expect(result).toBe('expired')
|
||||
})
|
||||
|
||||
it('sets usedAt to a non-null timestamp after redemption', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
expect(check.error).toContain('used')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listInviteCodes', () => {
|
||||
it('returns empty items and total 0 when no codes exist', async () => {
|
||||
const { db } = createTestApp()
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
expect(result).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('returns all codes when fewer than pageSize', async () => {
|
||||
const { db } = createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 3)
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('paginates correctly — page 1 returns first pageSize items', async () => {
|
||||
const { db } = createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 5)
|
||||
const result = await listInviteCodes(db, 1, 3)
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('paginates correctly — page 2 returns remaining items', async () => {
|
||||
const { db } = createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 5)
|
||||
const result = await listInviteCodes(db, 2, 3)
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns empty items on a page beyond total count', async () => {
|
||||
const { db } = createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 2)
|
||||
const result = await listInviteCodes(db, 5, 20)
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('orders results by createdAt descending', async () => {
|
||||
const { db } = createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 3)
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
const timestamps = result.items.map((item) => item.createdAt.getTime())
|
||||
const sorted = [...timestamps].sort((a, b) => b - a)
|
||||
expect(timestamps).toEqual(sorted)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteInviteCode', () => {
|
||||
it('returns ok and removes the code when it exists and is unused', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await deleteInviteCode(db, row.id)
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('removes the code from the database after deletion', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await deleteInviteCode(db, row.id)
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
expect(check.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found for a nonexistent code id', async () => {
|
||||
const { db } = createTestApp()
|
||||
const result = await deleteInviteCode(db, 'NOSUCHID')
|
||||
expect(result).toBe('not_found')
|
||||
})
|
||||
|
||||
it('returns already_used for a code that has been redeemed', async () => {
|
||||
const { db } = createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-99')
|
||||
const result = await deleteInviteCode(db, row.id)
|
||||
expect(result).toBe('already_used')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { and, count, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
import { inviteCodes } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
|
||||
export type InviteCode = typeof inviteCodes.$inferSelect
|
||||
|
||||
const generateCode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8)
|
||||
|
||||
export async function generateInviteCodes(
|
||||
db: Database,
|
||||
adminUserId: string,
|
||||
quantity: number,
|
||||
expiresAt?: Date,
|
||||
): Promise<InviteCode[]> {
|
||||
const now = new Date()
|
||||
const rows: InviteCode[] = Array.from({ length: quantity }, () => ({
|
||||
id: nanoid(),
|
||||
code: generateCode(),
|
||||
createdBy: adminUserId,
|
||||
usedBy: null,
|
||||
usedAt: null,
|
||||
expiresAt: expiresAt ?? null,
|
||||
createdAt: now,
|
||||
}))
|
||||
await db.insert(inviteCodes).values(rows)
|
||||
return rows
|
||||
}
|
||||
|
||||
export async function validateInviteCode(db: Database, code: string): Promise<{ valid: boolean; error?: string }> {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
|
||||
const row = rows[0]
|
||||
if (!row) return { valid: false, error: 'Invalid invite code' }
|
||||
if (row.usedBy) return { valid: false, error: 'Invite code already used' }
|
||||
if (row.expiresAt && row.expiresAt < new Date()) return { valid: false, error: 'Invite code expired' }
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export async function redeemInviteCode(
|
||||
db: Database,
|
||||
code: string,
|
||||
userId: string,
|
||||
): Promise<'ok' | 'not_found' | 'already_used' | 'expired'> {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
|
||||
const row = rows[0]
|
||||
if (!row) return 'not_found'
|
||||
if (row.usedBy) return 'already_used'
|
||||
if (row.expiresAt && row.expiresAt < new Date()) return 'expired'
|
||||
|
||||
const result = await db
|
||||
.update(inviteCodes)
|
||||
.set({ usedBy: userId, usedAt: new Date() })
|
||||
.where(and(eq(inviteCodes.code, code), isNull(inviteCodes.usedBy)))
|
||||
|
||||
// If no rows affected, another request redeemed it concurrently
|
||||
const changes = (result as { rowsAffected?: number }).rowsAffected ?? (result as { changes?: number }).changes ?? 1
|
||||
return changes > 0 ? 'ok' : 'already_used'
|
||||
}
|
||||
|
||||
export async function listInviteCodes(
|
||||
db: Database,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
): Promise<{ items: InviteCode[]; total: number }> {
|
||||
const [totalResult, items] = await Promise.all([
|
||||
db.select({ count: count() }).from(inviteCodes),
|
||||
db
|
||||
.select()
|
||||
.from(inviteCodes)
|
||||
.orderBy(desc(inviteCodes.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
])
|
||||
return { items, total: totalResult[0]?.count ?? 0 }
|
||||
}
|
||||
|
||||
export async function deleteInviteCode(db: Database, codeId: string): Promise<'ok' | 'not_found' | 'already_used'> {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.id, codeId))
|
||||
const row = rows[0]
|
||||
if (!row) return 'not_found'
|
||||
if (row.usedBy) return 'already_used'
|
||||
await db.delete(inviteCodes).where(eq(inviteCodes.id, codeId))
|
||||
return 'ok'
|
||||
}
|
||||
@@ -137,6 +137,15 @@ const APP_SCHEMA_SQL = `
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
public INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invite_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
created_by TEXT NOT NULL,
|
||||
used_by TEXT,
|
||||
used_at INTEGER,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
export function createTestApp() {
|
||||
|
||||
Reference in New Issue
Block a user