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:
Jasper Van
2026-04-12 21:31:50 -04:00
committed by GitHub
parent 4b20e6b5d9
commit 97e30a507e
9 changed files with 740 additions and 1 deletions
+11
View File
@@ -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`);
+8 -1
View File
@@ -36,6 +36,13 @@
"when": 1775100000000,
"tag": "0004_username_plugin",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1775110000000,
"tag": "0005_invite_codes",
"breakpoints": true
}
]
}
}
+5
View File
@@ -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
+10
View File
@@ -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(''),
+307
View File
@@ -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)
})
})
+56
View File
@@ -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)
})
+250
View File
@@ -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')
})
})
+84
View File
@@ -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'
}
+9
View File
@@ -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() {