refactor(api)!: enforce strict RESTful route conventions

- Replace verb-based URLs with proper HTTP methods and resource nouns:
  - Objects: merge PATCH /:id/done, /trash, /restore into PATCH /:id
    with discriminated union (action: update|confirm|trash|restore)
  - Objects: POST /:id/copy → POST /copy with copyFrom in body
  - Objects: POST /batch/move, /batch/trash → PATCH /batch;
    POST /batch/delete → DELETE /batch
  - Notifications: POST /:id/read → PATCH /:id,
    POST /read-all → PATCH /, GET /unread-count → GET /stats
  - Users: PUT /:id/status → PATCH /:id
  - Teams: POST /join → POST /:teamId/members
  - Email-config: POST /test → POST /test-messages
  - Invite-codes: POST /validate → POST /validations
- Fix path hierarchy: move admin auth-providers from
  /api/auth-providers/admin/* to /api/admin/auth-providers/*
- Rename recycle-bin to trash across API, frontend, and e2e tests
- Update all integration tests, CF tests, unit tests, and schemas

BREAKING CHANGE: all listed API endpoints have changed paths or methods

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
saltbo
2026-04-20 23:18:48 -04:00
parent f2f148298f
commit 41f3e45267
28 changed files with 594 additions and 430 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ async function signUpAndGoToTrash(page: import('@playwright/test').Page) {
])
expect(resp.status()).toBe(200)
await expect(page).toHaveURL(/files/, { timeout: 10000 })
await page.goto('/recycle-bin')
await expect(page).toHaveURL(/recycle-bin/, { timeout: 10000 })
await page.goto('/trash')
await expect(page).toHaveURL(/trash/, { timeout: 10000 })
}
// ---------------------------------------------------------------------------
+6 -4
View File
@@ -6,7 +6,7 @@ import { accessLog } from './middleware/logger'
import type { Env } from './middleware/platform'
import { platformMiddleware } from './middleware/platform'
import type { Platform } from './platform/interface'
import authProviders from './routes/auth-providers'
import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers'
import emailConfig from './routes/email-config'
import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes'
import { notifications } from './routes/notifications'
@@ -50,6 +50,7 @@ export function createApp(platform: Platform, auth: Auth) {
app.route('/dl', shareDirect)
app.route('/api/profiles', profile)
app.route('/api/teams', publicTeams)
app.route('/api/auth-providers', publicAuthProviders)
app.use('/api/*', authMiddleware)
@@ -57,7 +58,7 @@ export function createApp(platform: Platform, auth: Auth) {
// Each .route() call is independent — TypeScript doesn't stack types.
app.route('/api/objects', objects)
app.route('/api/shares', authedShares)
app.route('/api/recycle-bin', trash)
app.route('/api/trash', trash)
app.route('/api/teams', teams)
app.route('/api/admin/storages', storages)
app.route('/api/admin/users', users)
@@ -67,7 +68,7 @@ export function createApp(platform: Platform, auth: Auth) {
app.route('/api/admin/quotas', adminQuotas)
app.route('/api/quotas', userQuotas)
app.route('/api/system', system)
app.route('/api/auth-providers', authProviders)
app.route('/api/admin/auth-providers', adminAuthProviders)
app.route('/api/notifications', notifications)
app.get('/api/health', (c) => c.json({ status: 'ok' }))
@@ -90,7 +91,8 @@ export type SystemRoute = typeof system
export type EmailConfigRoute = typeof emailConfig
export type AdminInviteCodesRoute = typeof adminInviteCodes
export type PublicInviteCodesRoute = typeof publicInviteCodes
export type AuthProvidersRoute = typeof authProviders
export type AuthProvidersRoute = typeof publicAuthProviders
export type AdminAuthProvidersRoute = typeof adminAuthProviders
export type ProfileRoute = typeof profile
export type TeamsRoute = typeof teams
export type PublicTeamsRoute = typeof publicTeams
@@ -23,7 +23,7 @@ async function putProvider(
providerId: string,
body: Record<string, unknown>,
) {
return app.request(`/api/auth-providers/admin/${providerId}`, {
return app.request(`/api/admin/auth-providers/${providerId}`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
@@ -105,7 +105,7 @@ describe('Auth Providers — public list', () => {
describe('Auth Providers — admin list', () => {
it('returns 401 without authentication', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/auth-providers/admin')
const res = await app.request('/api/admin/auth-providers')
expect(res.status).toBe(401)
})
@@ -120,14 +120,14 @@ describe('Auth Providers — admin list', () => {
body: JSON.stringify({ email: 'regular@example.com', password: 'password123456' }),
})
const freshHeaders = { Cookie: signInRes.headers.getSetCookie().join('; ') }
const res = await app.request('/api/auth-providers/admin', { headers: freshHeaders })
const res = await app.request('/api/admin/auth-providers', { headers: freshHeaders })
expect(res.status).toBe(403)
})
it('returns empty items when no providers are configured', async () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
const res = await app.request('/api/auth-providers/admin', { headers: admin })
const res = await app.request('/api/admin/auth-providers', { headers: admin })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[] }
expect(body.items).toEqual([])
@@ -140,7 +140,7 @@ describe('Auth Providers — admin list', () => {
await putProvider(app, admin, 'github', { ...githubConfig, enabled: true })
await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id', enabled: false })
const res = await app.request('/api/auth-providers/admin', { headers: admin })
const res = await app.request('/api/admin/auth-providers', { headers: admin })
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
expect(body.items).toHaveLength(2)
})
@@ -151,7 +151,7 @@ describe('Auth Providers — admin list', () => {
await putProvider(app, admin, 'github', githubConfig)
const res = await app.request('/api/auth-providers/admin', { headers: admin })
const res = await app.request('/api/admin/auth-providers', { headers: admin })
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
const secret = body.items[0].clientSecret as string
expect(secret).toMatch(/^\*+alue$/)
@@ -164,7 +164,7 @@ describe('Auth Providers — admin list', () => {
await putProvider(app, admin, 'github', { ...githubConfig, clientSecret: 'abc' })
const res = await app.request('/api/auth-providers/admin', { headers: admin })
const res = await app.request('/api/admin/auth-providers', { headers: admin })
const body = (await res.json()) as { items: Array<Record<string, unknown>> }
expect(body.items[0].clientSecret).toBe('****')
})
@@ -173,7 +173,7 @@ describe('Auth Providers — admin list', () => {
describe('Auth Providers — admin upsert (PUT)', () => {
it('returns 401 without authentication', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/auth-providers/admin/github', {
const res = await app.request('/api/admin/auth-providers/github', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(githubConfig),
@@ -216,7 +216,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
expect(body.enabled).toBe(false)
// Admin list should still have only one entry
const listRes = await app.request('/api/auth-providers/admin', { headers: admin })
const listRes = await app.request('/api/admin/auth-providers', { headers: admin })
const listBody = (await listRes.json()) as { items: unknown[] }
expect(listBody.items).toHaveLength(1)
})
@@ -284,7 +284,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
describe('Auth Providers — admin delete', () => {
it('returns 401 without authentication', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE' })
const res = await app.request('/api/admin/auth-providers/github', { method: 'DELETE' })
expect(res.status).toBe(401)
})
@@ -294,7 +294,7 @@ describe('Auth Providers — admin delete', () => {
await putProvider(app, admin, 'github', githubConfig)
const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin })
const res = await app.request('/api/admin/auth-providers/github', { method: 'DELETE', headers: admin })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.deleted).toBe(true)
@@ -306,7 +306,7 @@ describe('Auth Providers — admin delete', () => {
const admin = await adminHeaders(app)
await putProvider(app, admin, 'github', githubConfig)
await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin })
await app.request('/api/admin/auth-providers/github', { method: 'DELETE', headers: admin })
const res = await app.request('/api/auth-providers')
const body = (await res.json()) as { items: unknown[] }
@@ -318,9 +318,9 @@ describe('Auth Providers — admin delete', () => {
const admin = await adminHeaders(app)
await putProvider(app, admin, 'github', githubConfig)
await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin })
await app.request('/api/admin/auth-providers/github', { method: 'DELETE', headers: admin })
const res = await app.request('/api/auth-providers/admin', { headers: admin })
const res = await app.request('/api/admin/auth-providers', { headers: admin })
const body = (await res.json()) as { items: unknown[] }
expect(body.items).toHaveLength(0)
})
@@ -329,7 +329,7 @@ describe('Auth Providers — admin delete', () => {
const { app } = await createTestApp()
const admin = await adminHeaders(app)
const res = await app.request('/api/auth-providers/admin/github', { method: 'DELETE', headers: admin })
const res = await app.request('/api/admin/auth-providers/github', { method: 'DELETE', headers: admin })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.deleted).toBe(true)
+25 -27
View File
@@ -32,28 +32,30 @@ const upsertSchema = z.object({
scopes: z.array(z.string()).optional(),
})
const app = new Hono<Env>()
// Public: enabled providers only, no secrets (for login page buttons)
// Public: enabled providers only, no secrets (for login page buttons)
export const publicAuthProviders = new Hono<Env>().get('/', async (c) => {
const db = c.get('platform').db
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
const items = rows
.map((r) => {
const config = parseProviderConfig(r.value)
if (!config?.enabled) return null
const meta = OAuthProviderMeta[config.providerId]
return {
providerId: config.providerId,
type: config.type,
name: meta?.name ?? config.providerId,
icon: meta?.icon ?? config.providerId,
}
})
.filter((item) => item !== null)
return c.json({ items })
})
// Admin: full CRUD with secrets masked
export const adminAuthProviders = new Hono<Env>()
.use(requireAdmin)
.get('/', async (c) => {
const db = c.get('platform').db
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
const items = rows
.map((r) => {
const config = parseProviderConfig(r.value)
if (!config?.enabled) return null
const meta = OAuthProviderMeta[config.providerId]
return {
providerId: config.providerId,
type: config.type,
name: meta?.name ?? config.providerId,
icon: meta?.icon ?? config.providerId,
}
})
.filter((item) => item !== null)
return c.json({ items })
})
// Admin: list all provider configs (secrets masked)
.get('/admin', requireAdmin, async (c) => {
const db = c.get('platform').db
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
const items = rows
@@ -65,8 +67,7 @@ const app = new Hono<Env>()
.filter((item) => item !== null)
return c.json({ items })
})
// Admin: upsert a provider config
.put('/admin/:providerId', requireAdmin, zValidator('json', upsertSchema), async (c) => {
.put('/:providerId', zValidator('json', upsertSchema), async (c) => {
const db = c.get('platform').db
const providerId = c.req.param('providerId')
const body = c.req.valid('json')
@@ -94,8 +95,7 @@ const app = new Hono<Env>()
return c.json({ ...config, clientSecret: maskSecret(config.clientSecret) })
})
// Admin: delete a provider config
.delete('/admin/:providerId', requireAdmin, async (c) => {
.delete('/:providerId', async (c) => {
const db = c.get('platform').db
const providerId = c.req.param('providerId')
if (!isValidProviderId(providerId)) {
@@ -104,5 +104,3 @@ const app = new Hono<Env>()
await db.delete(systemOptions).where(eq(systemOptions.key, optionKey(providerId)))
return c.json({ providerId, deleted: true })
})
export default app
@@ -56,7 +56,7 @@ describe('Admin Email Config API — auth', () => {
it('POST /test returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/admin/email-config/test', {
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'a@b.com' }),
@@ -285,7 +285,7 @@ describe('Admin Email Config API — POST /test', () => {
const headers = await adminHeaders(app)
await seedHttpConfig(db)
const res = await app.request('/api/admin/email-config/test', {
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
@@ -307,7 +307,7 @@ describe('Admin Email Config API — POST /test', () => {
const headers = await adminHeaders(app)
await seedHttpConfig(db)
const res = await app.request('/api/admin/email-config/test', {
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
@@ -322,7 +322,7 @@ describe('Admin Email Config API — POST /test', () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/email-config/test', {
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'recipient@example.com' }),
@@ -338,7 +338,7 @@ describe('Admin Email Config API — POST /test', () => {
const headers = await adminHeaders(app)
await seedSmtpConfig(db)
const res = await app.request('/api/admin/email-config/test', {
const res = await app.request('/api/admin/email-config/test-messages', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'not-an-email' }),
+1 -1
View File
@@ -111,7 +111,7 @@ const app = new Hono<Env>()
await saveOptions(db, entries)
return c.json({ success: true })
})
.post('/test', zValidator('json', testEmailSchema), async (c) => {
.post('/test-messages', zValidator('json', testEmailSchema), async (c) => {
const db = c.get('platform').db
const { to } = c.req.valid('json')
try {
+10 -10
View File
@@ -189,7 +189,7 @@ describe('Public Invite Codes API — POST /validate', () => {
const { app, db } = await createTestApp()
const [row] = await generateInviteCodes(db, 'admin-1', 1)
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: row.code }),
@@ -201,7 +201,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns valid:false for a nonexistent code', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'NOSUCHCD' }),
@@ -217,7 +217,7 @@ describe('Public Invite Codes API — POST /validate', () => {
const [row] = await generateInviteCodes(db, 'admin-1', 1)
await redeemInviteCode(db, row.code, 'user-99')
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: row.code }),
@@ -232,7 +232,7 @@ describe('Public Invite Codes API — POST /validate', () => {
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', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: row.code }),
@@ -244,7 +244,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns 400 when code field is missing from request body', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
@@ -254,7 +254,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns 400 when code is an empty string', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: '' }),
@@ -264,7 +264,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns 400 when code contains lowercase letters', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'abcd1234' }),
@@ -274,7 +274,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns 400 when code is fewer than 8 characters', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'ABC123' }),
@@ -284,7 +284,7 @@ describe('Public Invite Codes API — POST /validate', () => {
it('returns 400 when code is more than 8 characters', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: 'ABCD12345' }),
@@ -297,7 +297,7 @@ describe('Public Invite Codes API — POST /validate', () => {
const [row] = await generateInviteCodes(db, 'admin-1', 1)
// No auth headers — should still work
const res = await app.request('/api/invite-codes/validate', {
const res = await app.request('/api/invite-codes/validations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: row.code }),
+1 -1
View File
@@ -48,7 +48,7 @@ export const adminInviteCodes = new Hono<Env>()
return c.json({ id, deleted: true })
})
export const publicInviteCodes = new Hono<Env>().post('/validate', zValidator('json', validateSchema), async (c) => {
export const publicInviteCodes = new Hono<Env>().post('/validations', zValidator('json', validateSchema), async (c) => {
const db = c.get('platform').db
const { code } = c.req.valid('json')
const result = await validateInviteCode(db, code)
+14 -6
View File
@@ -38,28 +38,36 @@ describe('[CF] Notifications API', () => {
expect(body.unreadCount).toBe(0)
})
it('GET /api/notifications/unread-count returns 0', async () => {
it('GET /api/notifications/stats returns 0', async () => {
const app = await buildApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/notifications/unread-count', { headers })
const res = await app.request('/api/notifications/stats', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(0)
})
it('POST /api/notifications/read-all returns count 0 when empty', async () => {
it('PATCH /api/notifications returns count 0 when empty', async () => {
const app = await buildApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
const res = await app.request('/api/notifications', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(0)
})
it('POST /api/notifications/nonexistent/read returns 404', async () => {
it('PATCH /api/notifications/nonexistent returns 404', async () => {
const app = await buildApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/notifications/nonexistent/read', { method: 'POST', headers })
const res = await app.request('/api/notifications/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(404)
})
})
+54 -18
View File
@@ -80,7 +80,11 @@ describe('GET /api/notifications', () => {
const n1 = await createNotification(db, { userId, type: 'test', title: 'Read' })
await createNotification(db, { userId, type: 'test', title: 'Unread' })
await app.request(`/api/notifications/${n1.id}/read`, { method: 'POST', headers })
await app.request(`/api/notifications/${n1.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
const res = await app.request('/api/notifications?unread=true', { headers })
expect(res.status).toBe(200)
@@ -102,9 +106,9 @@ describe('GET /api/notifications', () => {
})
})
// ─── GET /api/notifications/unread-count ─────────────────────────────────────
// ─── GET /api/notifications/stats ─────────────────────────────────────
describe('GET /api/notifications/unread-count', () => {
describe('GET /api/notifications/stats', () => {
it('returns correct count', async () => {
const { app, db } = await createTestApp()
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
@@ -112,25 +116,29 @@ describe('GET /api/notifications/unread-count', () => {
await createNotification(db, { userId, type: 'test', title: 'A' })
await createNotification(db, { userId, type: 'test', title: 'B' })
const res = await app.request('/api/notifications/unread-count', { headers })
const res = await app.request('/api/notifications/stats', { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(2)
})
})
// ─── POST /api/notifications/:id/read ────────────────────────────────────────
// ─── PATCH /api/notifications/:id ────────────────────────────────────────
describe('POST /api/notifications/:id/read', () => {
describe('PATCH /api/notifications/:id', () => {
it('marks notification as read and returns 204', async () => {
const { app, db } = await createTestApp()
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
const res = await app.request(`/api/notifications/${n.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(204)
const countRes = await app.request('/api/notifications/unread-count', { headers })
const countRes = await app.request('/api/notifications/stats', { headers })
const body = (await countRes.json()) as { count: number }
expect(body.count).toBe(0)
})
@@ -140,8 +148,16 @@ describe('POST /api/notifications/:id/read', () => {
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
await app.request(`/api/notifications/${n.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
const res = await app.request(`/api/notifications/${n.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(204)
})
@@ -151,7 +167,11 @@ describe('POST /api/notifications/:id/read', () => {
const otherId = await insertUser(db)
const n = await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
const res = await app.request(`/api/notifications/${n.id}/read`, { method: 'POST', headers })
const res = await app.request(`/api/notifications/${n.id}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(404)
})
@@ -159,14 +179,18 @@ describe('POST /api/notifications/:id/read', () => {
const { app } = await createTestApp()
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
const res = await app.request('/api/notifications/nonexistent/read', { method: 'POST', headers })
const res = await app.request('/api/notifications/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(404)
})
})
// ─── POST /api/notifications/read-all ────────────────────────────────────────
// ─── PATCH /api/notifications ────────────────────────────────────────
describe('POST /api/notifications/read-all', () => {
describe('PATCH /api/notifications', () => {
it('marks all notifications as read and returns count', async () => {
const { app, db } = await createTestApp()
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
@@ -174,12 +198,16 @@ describe('POST /api/notifications/read-all', () => {
await createNotification(db, { userId, type: 'test', title: 'A' })
await createNotification(db, { userId, type: 'test', title: 'B' })
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
const res = await app.request('/api/notifications', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(2)
const countRes = await app.request('/api/notifications/unread-count', { headers })
const countRes = await app.request('/api/notifications/stats', { headers })
const countBody = (await countRes.json()) as { count: number }
expect(countBody.count).toBe(0)
})
@@ -190,7 +218,11 @@ describe('POST /api/notifications/read-all', () => {
const otherId = await insertUser(db)
await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
const res = await app.request('/api/notifications', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(0)
@@ -200,7 +232,11 @@ describe('POST /api/notifications/read-all', () => {
const { app } = await createTestApp()
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
const res = await app.request('/api/notifications/read-all', { method: 'POST', headers })
const res = await app.request('/api/notifications', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ read: true }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { count: number }
expect(body.count).toBe(0)
+3 -3
View File
@@ -18,13 +18,13 @@ export const notifications = new Hono<Env>()
const result = await listNotifications(db, userId, { page, pageSize, unreadOnly })
return c.json({ ...result, page, pageSize })
})
.get('/unread-count', async (c) => {
.get('/stats', async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const count = await unreadCount(db, userId)
return c.json({ count })
})
.post('/:id/read', async (c) => {
.patch('/:id', async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const { id } = c.req.param()
@@ -34,7 +34,7 @@ export const notifications = new Hono<Env>()
return new Response(null, { status: 204 })
})
.post('/read-all', async (c) => {
.patch('/', async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const result = await markAllAsRead(db, userId)
+43 -23
View File
@@ -71,9 +71,9 @@ async function setOrgQuota(
}
}
// ─── POST /api/objects/:id/copy — quota enforcement ──────────────────────────
// ─── POST /api/objects/copy — quota enforcement ──────────────────────────
describe('POST /api/objects/:id/copy — quota enforcement', () => {
describe('POST /api/objects/copy — quota enforcement', () => {
it('returns 422 when copying a file would exceed quota', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
@@ -83,10 +83,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
await setOrgQuota(db, orgId, 500, 450)
await insertFile(db, orgId, { id: 'm-copy-over', name: 'big.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-over/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-copy-over', parent: '' }),
})
expect(res.status).toBe(422)
const body = (await res.json()) as Record<string, unknown>
@@ -102,10 +102,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
await setOrgQuota(db, orgId, 1000, 100)
await insertFile(db, orgId, { id: 'm-copy-ok', name: 'doc.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-ok/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-copy-ok', parent: '' }),
})
expect(res.status).toBe(201)
@@ -121,10 +121,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
await setOrgQuota(db, orgId, 10000, 50)
await insertFile(db, orgId, { id: 'm-copy-st', name: 'img.png', size: 150 })
await app.request('/api/objects/m-copy-st/copy', {
await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-copy-st', parent: '' }),
})
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
@@ -145,10 +145,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
${validStorage.id}, 'active', ${now}, ${now})
`)
const res = await app.request('/api/objects/m-zero/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-zero', parent: '' }),
})
expect(res.status).toBe(201)
@@ -164,10 +164,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
// No org quota row at all — unlimited
await insertFile(db, orgId, { id: 'm-copy-nolimit', name: 'nolimit.txt', size: 100 })
const res = await app.request('/api/objects/m-copy-nolimit/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-copy-nolimit', parent: '' }),
})
expect(res.status).toBe(201)
})
@@ -180,10 +180,10 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
await setOrgQuota(db, orgId, 0, 99999)
await insertFile(db, orgId, { id: 'm-copy-qlimit', name: 'large.bin', size: 1000000 })
const res = await app.request('/api/objects/m-copy-qlimit/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm-copy-qlimit', parent: '' }),
})
expect(res.status).toBe(201)
})
@@ -192,18 +192,18 @@ describe('POST /api/objects/:id/copy — quota enforcement', () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'nonexistent', parent: '' }),
})
expect(res.status).toBe(404)
})
})
// ─── PATCH /api/objects/:id/done — quota enforcement via confirmUpload ─────────
// ─── PATCH /api/objects/:id (action: confirm) — quota enforcement via confirmUpload ─────────
describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload', () => {
describe('PATCH /api/objects/:id (action: confirm) — quota enforcement via confirmUpload', () => {
it('returns 200 and increments usage when quota allows', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
@@ -212,7 +212,11 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload',
await setOrgQuota(db, orgId, 10000, 0)
await insertFile(db, orgId, { id: 'm-done', name: 'uploading.txt', size: 350, status: 'draft' })
const res = await app.request('/api/objects/m-done/done', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m-done', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
@@ -229,7 +233,11 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload',
await setOrgQuota(db, orgId, 10000, 100)
await insertFile(db, orgId, { id: 'm-done2', name: 'photo.jpg', size: 400, status: 'draft' })
await app.request('/api/objects/m-done2/done', { method: 'PATCH', headers })
await app.request('/api/objects/m-done2', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
expect(storageRows[0].used).toBe(500)
@@ -244,7 +252,11 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload',
await setOrgQuota(db, orgId, 100, 90)
await insertFile(db, orgId, { id: 'm-done-quota', name: 'toobig.txt', size: 50, status: 'draft' })
const res = await app.request('/api/objects/m-done-quota/done', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m-done-quota', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(422)
const body = (await res.json()) as Record<string, unknown>
expect(body.error).toBe('Quota exceeded')
@@ -258,7 +270,11 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload',
await setOrgQuota(db, orgId, 10000, 50)
await insertFile(db, orgId, { id: 'm-done3', name: 'empty.txt', size: 0, status: 'draft' })
await app.request('/api/objects/m-done3/done', { method: 'PATCH', headers })
await app.request('/api/objects/m-done3', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${validStorage.id}`)
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
@@ -274,7 +290,11 @@ describe('PATCH /api/objects/:id/done — quota enforcement via confirmUpload',
// No quota row — unlimited
await insertFile(db, orgId, { id: 'm-done-nolimit', name: 'nolimit.txt', size: 5000, status: 'draft' })
const res = await app.request('/api/objects/m-done-nolimit/done', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m-done-nolimit', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
+168 -117
View File
@@ -225,7 +225,7 @@ describe('Objects API', () => {
const res = await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New Name' }),
body: JSON.stringify({ action: 'update', name: 'New Name' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
@@ -243,7 +243,7 @@ describe('Objects API', () => {
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Target Folder' }),
body: JSON.stringify({ action: 'update', parent: 'Target Folder' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
@@ -256,37 +256,39 @@ describe('Objects API', () => {
const res = await app.request('/api/objects/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Nope' }),
body: JSON.stringify({ action: 'update', name: 'Nope' }),
})
expect(res.status).toBe(404)
})
it('PATCH /api/objects/:id/done confirms upload', async () => {
it('PATCH /api/objects/:id (action: confirm) confirms upload', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'uploading.txt', status: 'draft' })
const res = await app.request('/api/objects/m1/done', {
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers,
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
})
it('PATCH /api/objects/:id/done returns 404 for non-draft object', async () => {
it('PATCH /api/objects/:id (action: confirm) returns 404 for non-draft object', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'already-active.txt', status: 'active' })
const res = await app.request('/api/objects/m1/done', {
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers,
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(404)
})
@@ -309,7 +311,11 @@ describe('Objects API', () => {
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'Delete Me' })
const trashRes = await app.request('/api/objects/f1/trash', { method: 'PATCH', headers })
const trashRes = await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
expect(trashRes.status).toBe(200)
const res = await app.request('/api/objects/f1', { method: 'DELETE', headers })
@@ -322,14 +328,18 @@ describe('Objects API', () => {
expect(check.status).toBe(404)
})
it('PATCH /api/objects/:id/trash trashes a file', async () => {
it('PATCH /api/objects/:id (action: trash) trashes a file', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/objects/m1/trash', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('trashed')
@@ -340,20 +350,24 @@ describe('Objects API', () => {
expect(listBody.total).toBe(1)
})
it('PATCH /api/objects/:id/restore restores a trashed file', async () => {
it('PATCH /api/objects/:id (action: restore) restores a trashed file', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' })
const res = await app.request('/api/objects/m1/restore', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restore' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
})
it('PATCH /api/objects/:id/trash cascades to folder children', async () => {
it('PATCH /api/objects/:id (action: trash) cascades to folder children', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -363,7 +377,11 @@ describe('Objects API', () => {
await insertFolder(db, orgId, { id: 'f2', name: 'Sub', parent: 'Parent' })
await insertFile(db, orgId, { id: 'm2', name: 'deep.txt', parent: 'f2' })
const res = await app.request('/api/objects/f1/trash', { method: 'PATCH', headers })
const res = await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
expect(res.status).toBe(200)
const trashed = await app.request('/api/objects?status=trashed', { headers })
@@ -372,13 +390,17 @@ describe('Objects API', () => {
expect(tBody.total).toBe(1)
// But all descendants are flagged trashed: restore restores them all
await app.request('/api/objects/f1/restore', { method: 'PATCH', headers })
await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restore' }),
})
const childRes = await app.request('/api/objects/m2', { headers })
const childBody = (await childRes.json()) as Record<string, unknown>
expect(childBody.status).toBe('active')
})
it('POST /api/recycle-bin/empty purges all trashed items', async () => {
it('DELETE /api/trash purges all trashed items', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -386,7 +408,7 @@ describe('Objects API', () => {
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' })
await insertFile(db, orgId, { id: 'm2', name: 'b.txt', status: 'trashed' })
const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers })
const res = await app.request('/api/trash', { method: 'DELETE', headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { purged: number }
expect(body.purged).toBe(2)
@@ -405,7 +427,7 @@ describe('Objects API', () => {
expect(res.status).toBe(404)
})
it('POST /api/objects/:id/copy copies a folder', async () => {
it('POST /api/objects/copy copies a folder', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -413,10 +435,10 @@ describe('Objects API', () => {
await insertFolder(db, orgId, { id: 'target', name: 'Dest' })
await insertFolder(db, orgId, { id: 'f1', name: 'Original' })
const res = await app.request('/api/objects/f1/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest' }),
body: JSON.stringify({ copyFrom: 'f1', parent: 'Dest' }),
})
expect(res.status).toBe(201)
const body = (await res.json()) as Record<string, unknown>
@@ -426,23 +448,24 @@ describe('Objects API', () => {
expect(body.parent).toBe('Dest')
})
it('POST /api/objects/:id/copy returns 404 for missing source', async () => {
it('POST /api/objects/copy returns 404 for missing source', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ copyFrom: 'nonexistent' }),
})
expect(res.status).toBe(404)
})
it('PATCH /api/objects/:id/done returns 404 for missing object', async () => {
it('PATCH /api/objects/:id (action: confirm) returns 404 for missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/done', {
const res = await app.request('/api/objects/nonexistent', {
method: 'PATCH',
headers,
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(404)
})
@@ -463,17 +486,17 @@ describe('Objects API', () => {
expect(body.object).toBeTruthy()
})
it('POST /api/objects/:id/copy copies a file with S3', async () => {
it('POST /api/objects/copy copies a file with S3', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'doc.txt' })
const res = await app.request('/api/objects/m1/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: '' }),
body: JSON.stringify({ copyFrom: 'm1', parent: '' }),
})
expect(res.status).toBe(201)
expect(S3Service.prototype.copyObject).toHaveBeenCalled()
@@ -486,7 +509,11 @@ describe('Objects API', () => {
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
await app.request('/api/objects/m1/trash', { method: 'PATCH', headers })
await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
const res = await app.request('/api/objects/m1', { method: 'DELETE', headers })
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
@@ -503,7 +530,11 @@ describe('Objects API', () => {
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', parent: 'Folder' })
await insertFile(db, orgId, { id: 'm2', name: 'b.txt', parent: 'Folder' })
await app.request('/api/objects/f1/trash', { method: 'PATCH', headers })
await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
const res = await app.request('/api/objects/f1', { method: 'DELETE', headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { purged: number }
@@ -511,58 +542,74 @@ describe('Objects API', () => {
expect(S3Service.prototype.deleteObjects).toHaveBeenCalled()
})
it('PATCH /api/objects/:id/trash returns 404 for missing object', async () => {
it('PATCH /api/objects/:id (action: trash) returns 404 for missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/trash', { method: 'PATCH', headers })
const res = await app.request('/api/objects/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
expect(res.status).toBe(404)
})
it('PATCH /api/objects/:id/trash is idempotent for already-trashed item', async () => {
it('PATCH /api/objects/:id (action: trash) is idempotent for already-trashed item', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' })
const res = await app.request('/api/objects/m1/trash', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('trashed')
})
it('PATCH /api/objects/:id/restore returns 404 for missing object', async () => {
it('PATCH /api/objects/:id (action: restore) returns 404 for missing object', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/nonexistent/restore', { method: 'PATCH', headers })
const res = await app.request('/api/objects/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restore' }),
})
expect(res.status).toBe(404)
})
it('PATCH /api/objects/:id/restore is no-op for active item', async () => {
it('PATCH /api/objects/:id (action: restore) is no-op for active item', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'active' })
const res = await app.request('/api/objects/m1/restore', { method: 'PATCH', headers })
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'restore' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as Record<string, unknown>
expect(body.status).toBe('active')
})
it('POST /api/recycle-bin/empty with files calls S3 deleteObjects', async () => {
it('DELETE /api/trash with files calls S3 deleteObjects', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt', status: 'trashed' })
const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers })
const res = await app.request('/api/trash', { method: 'DELETE', headers })
expect(res.status).toBe(200)
expect(S3Service.prototype.deleteObjects).toHaveBeenCalled()
})
it('POST /api/recycle-bin/empty handles folders (no S3 object) and files together', async () => {
it('DELETE /api/trash handles folders (no S3 object) and files together', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -571,18 +618,22 @@ describe('Objects API', () => {
await insertFile(db, orgId, { id: 'm1', name: 'child.txt', parent: 'Trash Folder' })
// Trash the folder (cascades to child)
await app.request('/api/objects/f1/trash', { method: 'PATCH', headers })
await app.request('/api/objects/f1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'trash' }),
})
const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers })
const res = await app.request('/api/trash', { method: 'DELETE', headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { purged: number }
expect(body.purged).toBe(2)
})
it('POST /api/recycle-bin/empty returns 0 when trash is empty', async () => {
it('DELETE /api/trash returns 0 when trash is empty', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/recycle-bin/empty', { method: 'POST', headers })
const res = await app.request('/api/trash', { method: 'DELETE', headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { purged: number }
expect(body.purged).toBe(0)
@@ -601,7 +652,7 @@ describe('Objects API', () => {
expect(body.downloadUrl).toBe('https://presigned-download.example.com')
})
it('POST /api/objects/batch/move moves multiple items', async () => {
it('PATCH /api/objects/batch (action: move) moves multiple items', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -609,42 +660,42 @@ describe('Objects API', () => {
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'b.txt' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1', 'm2'], parent: 'target' }),
body: JSON.stringify({ action: 'move', ids: ['m1', 'm2'], parent: 'target' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { moved: number }
expect(body.moved).toBe(2)
})
it('POST /api/objects/batch/move returns 400 for invalid input', async () => {
it('PATCH /api/objects/batch (action: move) returns 400 for invalid input', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: [] }),
body: JSON.stringify({ action: 'move', ids: [] }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/move returns 400 if any id missing from org', async () => {
it('PATCH /api/objects/batch (action: move) returns 400 if any id missing from org', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1', 'nope'], parent: 'x' }),
body: JSON.stringify({ action: 'move', ids: ['m1', 'nope'], parent: 'x' }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/trash trashes items and cascades', async () => {
it('PATCH /api/objects/batch (action: trash) trashes items and cascades', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -652,28 +703,28 @@ describe('Objects API', () => {
await insertFolder(db, orgId, { id: 'f1', name: 'folder' })
await insertFile(db, orgId, { id: 'c1', name: 'child.txt', parent: 'folder' })
const res = await app.request('/api/objects/batch/trash', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['f1'] }),
body: JSON.stringify({ action: 'trash', ids: ['f1'] }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { trashed: number }
expect(body.trashed).toBe(2)
})
it('POST /api/objects/batch/trash returns 400 for invalid input', async () => {
it('PATCH /api/objects/batch (action: trash) returns 400 for invalid input', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/trash', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ action: 'trash' }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/delete permanently deletes trashed items', async () => {
it('DELETE /api/objects/batch permanently deletes trashed items', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -686,8 +737,8 @@ describe('Objects API', () => {
('t2', ${orgId}, 't2-a', 't2', 'folder', 0, 1, '', '', ${validStorage.id}, 'trashed', ${now}, ${now})
`)
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'DELETE',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['t1', 't2'] }),
})
@@ -696,7 +747,7 @@ describe('Objects API', () => {
expect(body.deleted).toBe(2)
})
it('POST /api/objects/batch/delete returns 400 if any item is not trashed', async () => {
it('DELETE /api/objects/batch returns 400 if any item is not trashed', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -704,26 +755,26 @@ describe('Objects API', () => {
await insertFile(db, orgId, { id: 'tx', name: 'a.txt', status: 'trashed' })
await insertFile(db, orgId, { id: 'ax', name: 'b.txt', status: 'active' })
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'DELETE',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['tx', 'ax'] }),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/delete returns 400 for invalid input', async () => {
it('DELETE /api/objects/batch returns 400 for invalid input', async () => {
const { app } = await createTestApp()
const headers = await authedHeaders(app)
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'DELETE',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(res.status).toBe(400)
})
it('POST /api/objects/batch/delete decrements usage for files with size > 0', async () => {
it('DELETE /api/objects/batch decrements usage for files with size > 0', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -738,8 +789,8 @@ describe('Objects API', () => {
// Set storage used to match total file sizes
await db.run(sql`UPDATE storages SET used = 500 WHERE id = ${validStorage.id}`)
const res = await app.request('/api/objects/batch/delete', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'DELETE',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['td1', 'td2'] }),
})
@@ -751,41 +802,41 @@ describe('Objects API', () => {
expect(storageRows[0].used).toBe(0)
})
it('POST /api/objects/batch/trash returns 400 when IDs do not belong to org', async () => {
it('PATCH /api/objects/batch (action: trash) returns 400 when IDs do not belong to org', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFile(db, orgId, { id: 'm1', name: 'a.txt' })
const res = await app.request('/api/objects/batch/trash', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1', 'does-not-exist'] }),
body: JSON.stringify({ action: 'trash', ids: ['m1', 'does-not-exist'] }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as Record<string, unknown>
expect(typeof body.error).toBe('string')
})
it('POST /api/objects/batch/move returns 400 when moving folder into itself', async () => {
it('PATCH /api/objects/batch (action: move) returns 400 when moving folder into itself', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const orgId = await getOrgId(db)
await insertFolder(db, orgId, { id: 'f1', name: 'ParentFolder' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['f1'], parent: 'ParentFolder' }),
body: JSON.stringify({ action: 'move', ids: ['f1'], parent: 'ParentFolder' }),
})
expect(res.status).toBe(400)
const body = (await res.json()) as Record<string, unknown>
expect(typeof body.error).toBe('string')
})
it('POST /api/objects/batch/move cascades path when moving a folder', async () => {
it('PATCH /api/objects/batch (action: move) cascades path when moving a folder', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -794,10 +845,10 @@ describe('Objects API', () => {
await insertFolder(db, orgId, { id: 'f2', name: 'Target' })
await insertFile(db, orgId, { id: 'm1', name: 'child.txt', parent: 'FolderA' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['f1'], parent: 'Target' }),
body: JSON.stringify({ action: 'move', ids: ['f1'], parent: 'Target' }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { moved: number }
@@ -1259,7 +1310,7 @@ describe('Objects API — name conflict (409 responses)', () => {
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'beta.txt' }),
body: JSON.stringify({ action: 'update', name: 'beta.txt' }),
})
expect(res.status).toBe(409)
@@ -1279,7 +1330,7 @@ describe('Objects API — name conflict (409 responses)', () => {
const res = await app.request('/api/objects/m1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'beta.txt', onConflict: 'rename' }),
body: JSON.stringify({ action: 'update', name: 'beta.txt', onConflict: 'rename' }),
})
expect(res.status).toBe(200)
@@ -1287,7 +1338,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.name).toBe('beta (1).txt')
})
it('POST /api/objects/batch/move with collision and no onConflict returns 409', async () => {
it('PATCH /api/objects/batch (action: move) with collision and no onConflict returns 409', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1295,10 +1346,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'file.txt', parent: 'Dest' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1'], parent: 'Dest' }),
body: JSON.stringify({ action: 'move', ids: ['m1'], parent: 'Dest' }),
})
expect(res.status).toBe(409)
@@ -1306,7 +1357,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.code).toBe('NAME_CONFLICT')
})
it('POST /api/objects/batch/move with onConflict: rename resolves collision', async () => {
it('PATCH /api/objects/batch (action: move) with onConflict: rename resolves collision', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1314,10 +1365,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'm1', name: 'file.txt' })
await insertFile(db, orgId, { id: 'm2', name: 'file.txt', parent: 'Dest' })
const res = await app.request('/api/objects/batch/move', {
method: 'POST',
const res = await app.request('/api/objects/batch', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: ['m1'], parent: 'Dest', onConflict: 'rename' }),
body: JSON.stringify({ action: 'move', ids: ['m1'], parent: 'Dest', onConflict: 'rename' }),
})
expect(res.status).toBe(200)
@@ -1325,7 +1376,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.moved).toBe(1)
})
it('PATCH /api/objects/:id/done returns 409 when active sibling was created during upload', async () => {
it('PATCH /api/objects/:id (action: confirm) returns 409 when active sibling was created during upload', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1334,10 +1385,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'draft1', name: 'upload.txt', status: 'draft' })
await insertFile(db, orgId, { id: 'active1', name: 'upload.txt', status: 'active' })
const res = await app.request('/api/objects/draft1/done', {
const res = await app.request('/api/objects/draft1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ action: 'confirm' }),
})
expect(res.status).toBe(409)
@@ -1345,7 +1396,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.code).toBe('NAME_CONFLICT')
})
it('PATCH /api/objects/:id/restore returns 409 when restore name is already taken', async () => {
it('PATCH /api/objects/:id (action: restore) returns 409 when restore name is already taken', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1353,10 +1404,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'trashed1', name: 'note.txt', status: 'trashed' })
await insertFile(db, orgId, { id: 'active2', name: 'note.txt', status: 'active' })
const res = await app.request('/api/objects/trashed1/restore', {
const res = await app.request('/api/objects/trashed1', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
body: JSON.stringify({ action: 'restore' }),
})
expect(res.status).toBe(409)
@@ -1364,7 +1415,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.code).toBe('NAME_CONFLICT')
})
it('PATCH /api/objects/:id/restore with onConflict: rename restores with suffix', async () => {
it('PATCH /api/objects/:id (action: restore) with onConflict: rename restores with suffix', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1372,10 +1423,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'trashed2', name: 'note.txt', status: 'trashed' })
await insertFile(db, orgId, { id: 'active3', name: 'note.txt', status: 'active' })
const res = await app.request('/api/objects/trashed2/restore', {
const res = await app.request('/api/objects/trashed2', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ onConflict: 'rename' }),
body: JSON.stringify({ action: 'restore', onConflict: 'rename' }),
})
expect(res.status).toBe(200)
@@ -1384,7 +1435,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.status).toBe('active')
})
it('POST /api/objects/:id/copy returns 409 when onConflict: fail and target has same name', async () => {
it('POST /api/objects/copy returns 409 when onConflict: fail and target has same name', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1392,10 +1443,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'src1', name: 'doc.txt' })
await insertFile(db, orgId, { id: 'dst1', name: 'doc.txt', parent: 'Dest' })
const res = await app.request('/api/objects/src1/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest', onConflict: 'fail' }),
body: JSON.stringify({ copyFrom: 'src1', parent: 'Dest', onConflict: 'fail' }),
})
expect(res.status).toBe(409)
@@ -1403,7 +1454,7 @@ describe('Objects API — name conflict (409 responses)', () => {
expect(body.code).toBe('NAME_CONFLICT')
})
it('POST /api/objects/:id/copy auto-renames by default when target has same name', async () => {
it('POST /api/objects/copy auto-renames by default when target has same name', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
@@ -1411,10 +1462,10 @@ describe('Objects API — name conflict (409 responses)', () => {
await insertFile(db, orgId, { id: 'src2', name: 'photo.jpg' })
await insertFile(db, orgId, { id: 'dst2', name: 'photo.jpg', parent: 'Dest' })
const res = await app.request('/api/objects/src2/copy', {
const res = await app.request('/api/objects/copy', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ parent: 'Dest' }),
body: JSON.stringify({ copyFrom: 'src2', parent: 'Dest' }),
})
expect(res.status).toBe(201)
+70 -78
View File
@@ -2,13 +2,11 @@ import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { DirType } from '../../shared/constants'
import {
batchIdsSchema,
batchMoveSchema,
confirmUploadSchema,
batchDeleteSchema,
batchPatchSchema,
copyMatterSchema,
createMatterSchema,
restoreMatterSchema,
updateMatterSchema,
patchMatterSchema,
} from '../../shared/schemas'
import type { Storage as S3Storage } from '../../shared/types'
import { requireAuth, requireTeamRole } from '../middleware/auth'
@@ -107,35 +105,35 @@ const app = new Hono<Env>()
throw e
}
})
.post('/batch/move', requireTeamRole('editor'), zValidator('json', batchMoveSchema), async (c) => {
.patch('/batch', requireTeamRole('editor'), zValidator('json', batchPatchSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const { ids, parent, onConflict } = c.req.valid('json')
const body = c.req.valid('json')
const db = c.get('platform').db
const userId = c.get('userId')!
try {
const moved = await batchMove(db, orgId, ids, parent, userId, onConflict ?? 'fail')
return c.json({ moved: moved.length })
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
})
.post('/batch/trash', requireTeamRole('editor'), zValidator('json', batchIdsSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const { ids } = c.req.valid('json')
const db = c.get('platform').db
try {
const trashed = await batchTrash(db, orgId, ids)
return c.json({ trashed: trashed.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
switch (body.action) {
case 'move': {
const userId = c.get('userId')!
try {
const moved = await batchMove(db, orgId, body.ids, body.parent, userId, body.onConflict ?? 'fail')
return c.json({ moved: moved.length })
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
}
case 'trash': {
try {
const trashed = await batchTrash(db, orgId, body.ids)
return c.json({ trashed: trashed.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
}
}
}
})
.post('/batch/delete', requireTeamRole('editor'), zValidator('json', batchIdsSchema), async (c) => {
.delete('/batch', requireTeamRole('editor'), zValidator('json', batchDeleteSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
@@ -179,60 +177,54 @@ const app = new Hono<Env>()
const downloadUrl = await s3.presignDownload(storage, matter.object, matter.name)
return c.json({ ...matter, downloadUrl })
})
.patch('/:id', requireTeamRole('editor'), zValidator('json', updateMatterSchema), async (c) => {
.patch('/:id', requireTeamRole('editor'), zValidator('json', patchMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
try {
const matter = await updateMatter(db, c.req.param('id'), orgId, c.req.valid('json'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
})
.patch('/:id/done', requireTeamRole('editor'), zValidator('json', confirmUploadSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const body = c.req.valid('json')
const db = c.get('platform').db
const userId = c.get('userId')!
const { onConflict } = c.req.valid('json')
try {
const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, { onConflict, userId })
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
})
.patch('/:id/trash', requireTeamRole('editor'), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const matter = await trashMatter(db, orgId, c.req.param('id'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
})
.patch('/:id/restore', requireTeamRole('editor'), zValidator('json', restoreMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const { onConflict } = c.req.valid('json')
try {
const matter = await restoreMatter(db, orgId, c.req.param('id'), userId, onConflict ?? 'fail')
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
switch (body.action) {
case 'update': {
try {
const matter = await updateMatter(db, c.req.param('id'), orgId, body, userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
return c.json({ error: (e as Error).message }, 400)
}
}
case 'confirm': {
try {
const { matter, quotaExceeded } = await confirmUpload(db, c.req.param('id'), orgId, {
onConflict: body.onConflict,
userId,
})
if (quotaExceeded) return c.json({ error: 'Quota exceeded' }, 422)
if (!matter) return c.json({ error: 'Not found or not in draft status' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
}
case 'trash': {
const matter = await trashMatter(db, orgId, c.req.param('id'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
}
case 'restore': {
try {
const matter = await restoreMatter(db, orgId, c.req.param('id'), userId, body.onConflict ?? 'fail')
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
} catch (e) {
if (e instanceof NameConflictError) return c.json(conflictBody(e), 409)
throw e
}
}
}
})
.delete('/:id', requireTeamRole('editor'), async (c) => {
@@ -247,13 +239,14 @@ const app = new Hono<Env>()
const purged = await purgeRecursively(db, orgId, ms)
return c.json({ id: ms[0].id, deleted: true, purged })
})
.post('/:id/copy', requireTeamRole('editor'), zValidator('json', copyMatterSchema), async (c) => {
.post('/copy', requireTeamRole('editor'), zValidator('json', copyMatterSchema), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const userId = c.get('userId')!
const source = await getMatter(db, c.req.param('id'), orgId)
const { copyFrom, parent, onConflict } = c.req.valid('json')
const source = await getMatter(db, copyFrom, orgId)
if (!source) return c.json({ error: 'Not found' }, 404)
const sourceSize = source.size ?? 0
@@ -274,7 +267,6 @@ const app = new Hono<Env>()
await s3.copyObject(storage, source.object, storage, newObject)
}
const { parent, onConflict } = c.req.valid('json')
try {
const copy = await copyMatter(db, source, parent, newObject, { onConflict, userId })
return c.json(copy, 201)
+6 -6
View File
@@ -166,12 +166,12 @@ describe('GET /api/teams/:teamId/invitations', () => {
})
})
// ─── POST /join ────────────────────────────────────────────────────────────────
// ─── POST /:teamId/members ─────────────────────────────────────────────────────
describe('POST /api/teams/join', () => {
describe('POST /api/teams/:teamId/members', () => {
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/teams/join', {
const res = await app.request('/api/teams/some-team/members', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'some-token' }),
@@ -184,7 +184,7 @@ describe('POST /api/teams/join', () => {
const email = `joiner-${nanoid()}@example.com`
const { headers } = await signUpAndGetUser(app, email)
const res = await app.request('/api/teams/join', {
const res = await app.request('/api/teams/some-team/members', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ token: 'invalid-token' }),
@@ -201,7 +201,7 @@ describe('POST /api/teams/join', () => {
const email = `newmember-${nanoid()}@example.com`
const { headers } = await signUpAndGetUser(app, email)
const res = await app.request('/api/teams/join', {
const res = await app.request(`/api/teams/${orgId}/members`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ token: link.token }),
@@ -219,7 +219,7 @@ describe('POST /api/teams/join', () => {
const { headers, userId } = await signUpAndGetUser(app, email)
await insertMember(db, orgId, userId, 'viewer')
const res = await app.request('/api/teams/join', {
const res = await app.request(`/api/teams/${orgId}/members`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ token: link.token }),
+1 -1
View File
@@ -58,7 +58,7 @@ export const teams = new Hono<Env>()
const invitations = await listPendingInvitations(db, teamId)
return c.json({ invitations })
})
.post('/join', zValidator('json', joinSchema), async (c) => {
.post('/:teamId/members', zValidator('json', joinSchema), async (c) => {
const db = c.get('platform').db
const userId = c.get('userId')!
const { token } = c.req.valid('json')
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Env } from '../middleware/platform'
import { collectForPurge, listTrashedRoots } from '../services/matter'
import { purgeRecursively } from '../services/purge'
const app = new Hono<Env>().use(requireAuth).post('/empty', requireTeamRole('editor'), async (c) => {
const app = new Hono<Env>().use(requireAuth).delete('/', requireTeamRole('editor'), async (c) => {
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
+11 -11
View File
@@ -59,7 +59,7 @@ describe('Admin Users API', () => {
expect(body.items[0].orgName).toBeTruthy()
})
it('PUT /api/admin/users/:id/status disables a user', async () => {
it('PATCH /api/admin/users/:id disables a user', async () => {
const { app, db } = await createTestApp()
const headers = await adminHeaders(app)
@@ -70,8 +70,8 @@ describe('Admin Users API', () => {
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = 'user2@example.com'`)
const userId = users[0].id
const res = await app.request(`/api/admin/users/${userId}/status`, {
method: 'PUT',
const res = await app.request(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'disabled' }),
})
@@ -84,22 +84,22 @@ describe('Admin Users API', () => {
expect(updated[0].banned).toBe(1)
})
it('PUT /api/admin/users/:id/status rejects invalid status', async () => {
it('PATCH /api/admin/users/:id rejects invalid status', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/users/someid/status', {
method: 'PUT',
const res = await app.request('/api/admin/users/someid', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'invalid' }),
})
expect(res.status).toBe(400)
})
it('PUT /api/admin/users/:id/status returns 404 for missing user', async () => {
it('PATCH /api/admin/users/:id returns 404 for missing user', async () => {
const { app } = await createTestApp()
const headers = await adminHeaders(app)
const res = await app.request('/api/admin/users/nonexistent/status', {
method: 'PUT',
const res = await app.request('/api/admin/users/nonexistent', {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'disabled' }),
})
@@ -138,8 +138,8 @@ describe('Admin Users API', () => {
const userId = users[0].id
// Disable the user while they have an active session
await app.request(`/api/admin/users/${userId}/status`, {
method: 'PUT',
await app.request(`/api/admin/users/${userId}`, {
method: 'PATCH',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'disabled' }),
})
+1 -1
View File
@@ -19,7 +19,7 @@ const app = new Hono<Env>()
const result = await listUsers(db, page, pageSize)
return c.json(result)
})
.put('/:id/status', zValidator('json', updateStatusSchema), async (c) => {
.patch('/:id', zValidator('json', updateStatusSchema), async (c) => {
const db = c.get('platform').db
const userId = c.req.param('id')
const { status } = c.req.valid('json')
+51 -14
View File
@@ -39,6 +39,7 @@ export const createMatterSchema = z.object({
export type CreateMatterInput = z.infer<typeof createMatterSchema>
export const updateMatterSchema = z.object({
action: z.literal('update').optional().default('update'),
name: z.string().min(1).optional(),
parent: z.string().optional(),
onConflict: conflictStrategySchema.optional(),
@@ -46,25 +47,61 @@ export const updateMatterSchema = z.object({
export type UpdateMatterInput = z.infer<typeof updateMatterSchema>
export const copyMatterSchema = z.object({
parent: z.string().default(''),
export const confirmMatterSchema = z.object({
action: z.literal('confirm'),
onConflict: conflictStrategySchema.optional(),
})
export const batchMoveSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
parent: z.string().default(''),
onConflict: conflictStrategySchema.optional(),
})
export const batchIdsSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
})
export const confirmUploadSchema = z.object({
onConflict: conflictStrategySchema.optional(),
export const trashMatterSchema = z.object({
action: z.literal('trash'),
})
export const restoreMatterSchema = z.object({
action: z.literal('restore'),
onConflict: conflictStrategySchema.optional(),
})
export const patchMatterSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('update'),
name: z.string().min(1).optional(),
parent: z.string().optional(),
onConflict: conflictStrategySchema.optional(),
}),
z.object({
action: z.literal('confirm'),
onConflict: conflictStrategySchema.optional(),
}),
z.object({
action: z.literal('trash'),
}),
z.object({
action: z.literal('restore'),
onConflict: conflictStrategySchema.optional(),
}),
])
export type PatchMatterInput = z.infer<typeof patchMatterSchema>
export const copyMatterSchema = z.object({
copyFrom: z.string().min(1),
parent: z.string().default(''),
onConflict: conflictStrategySchema.optional(),
})
export const batchPatchSchema = z.discriminatedUnion('action', [
z.object({
action: z.literal('move'),
ids: z.array(z.string().min(1)).min(1),
parent: z.string().default(''),
onConflict: conflictStrategySchema.optional(),
}),
z.object({
action: z.literal('trash'),
ids: z.array(z.string().min(1)).min(1),
}),
])
export const batchDeleteSchema = z.object({
ids: z.array(z.string().min(1)).min(1),
})
+9 -3
View File
@@ -105,16 +105,22 @@ describe('updateMatterSchema', () => {
})
describe('copyMatterSchema', () => {
it('accepts empty object with default parent', () => {
it('requires copyFrom field', () => {
const result = copyMatterSchema.safeParse({})
expect(result.success).toBe(false)
})
it('accepts copyFrom with default parent', () => {
const result = copyMatterSchema.safeParse({ copyFrom: 'source-id' })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.parent).toBe('')
expect(result.data.copyFrom).toBe('source-id')
}
})
it('accepts explicit parent', () => {
const result = copyMatterSchema.safeParse({ parent: 'folder-id' })
it('accepts copyFrom with explicit parent', () => {
const result = copyMatterSchema.safeParse({ copyFrom: 'source-id', parent: 'folder-id' })
expect(result.success).toBe(true)
})
})
+2 -2
View File
@@ -77,7 +77,7 @@ export function AppSidebar() {
const isFiles = pathname === '/files'
const activeFilesNav = isFiles && !fileType
const activeFileType = (type: string) => isFiles && fileType === type
const activeRecycleBin = pathname === '/recycle-bin'
const activeRecycleBin = pathname === '/trash'
const activeShares = pathname.startsWith('/shares')
async function handleSignOut() {
@@ -156,7 +156,7 @@ export function AppSidebar() {
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={activeRecycleBin}>
<Link to="/recycle-bin">
<Link to="/trash">
<Trash2 className="h-4 w-4" />
<span>{t('nav.trash')}</span>
</Link>
+43 -37
View File
@@ -196,7 +196,7 @@ describe('api', () => {
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/id1')
expect(init.method).toBe('PATCH')
expect(init.body).toBe(JSON.stringify({ name: 'renamed.txt' }))
expect(init.body).toBe(JSON.stringify({ action: 'update', name: 'renamed.txt' }))
})
it('patches object by id with parent', async () => {
@@ -206,7 +206,7 @@ describe('api', () => {
await updateObject('id1', { parent: 'folder2' })
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(init.body).toBe(JSON.stringify({ parent: 'folder2' }))
expect(init.body).toBe(JSON.stringify({ action: 'update', parent: 'folder2' }))
})
it('throws on error response', async () => {
@@ -217,7 +217,7 @@ describe('api', () => {
})
describe('confirmUpload', () => {
it('patches /done endpoint', async () => {
it('patches with action: confirm', async () => {
const obj = { id: 'id1', status: 'active' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
@@ -225,8 +225,10 @@ describe('api', () => {
expect(result).toEqual(obj)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/objects/id1/done')
expect(url).toBe('/api/objects/id1')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ action: 'confirm' })
})
it('throws on error response', async () => {
@@ -257,7 +259,7 @@ describe('api', () => {
})
describe('copyObject', () => {
it('posts to /copy endpoint with parent in body', async () => {
it('posts to /copy endpoint with copyFrom and parent in body', async () => {
const copy = { id: 'copy1', name: 'file.txt' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(copy))
@@ -265,10 +267,10 @@ describe('api', () => {
expect(result).toEqual(copy)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/id1/copy')
expect(url).toContain('/api/objects/copy')
expect(init.method).toBe('POST')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ parent: 'folder2' })
expect(body).toMatchObject({ copyFrom: 'id1', parent: 'folder2' })
const headers =
init.headers instanceof Headers ? init.headers : new Headers(init.headers as Record<string, string>)
expect(headers.get('Content-Type')).toContain('application/json')
@@ -324,7 +326,7 @@ describe('api', () => {
})
describe('restoreObject', () => {
it('sends PATCH to restore endpoint for the given id', async () => {
it('sends PATCH with action: restore for the given id', async () => {
const obj = { id: 'id1', status: 'active' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
@@ -332,8 +334,10 @@ describe('api', () => {
expect(result).toEqual(obj)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/id1/restore')
expect(url).toContain('/api/objects/id1')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ action: 'restore' })
})
it('throws on error response', async () => {
@@ -344,7 +348,7 @@ describe('api', () => {
})
describe('emptyTrash', () => {
it('sends POST to empty trash endpoint', async () => {
it('sends DELETE to trash endpoint', async () => {
const payload = { purged: 5 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
@@ -352,8 +356,8 @@ describe('api', () => {
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/recycle-bin/empty')
expect(init.method).toBe('POST')
expect(url).toContain('/api/trash')
expect(init.method).toBe('DELETE')
})
it('throws on error response', async () => {
@@ -501,7 +505,7 @@ describe('api', () => {
})
describe('updateUserStatus', () => {
it('puts user status and returns updated user', async () => {
it('patches user and returns updated user', async () => {
const updated = { id: 'u1', status: 'active' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(updated))
@@ -509,8 +513,8 @@ describe('api', () => {
expect(result).toEqual(updated)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/admin/users/u1/status')
expect(init.method).toBe('PUT')
expect(url).toContain('/api/admin/users/u1')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ status: 'active' })
})
@@ -719,7 +723,7 @@ describe('api', () => {
})
describe('trashObject', () => {
it('sends PATCH to trash endpoint for the given id', async () => {
it('sends PATCH with action: trash for the given id', async () => {
const obj = { id: 'id1', status: 'trashed' }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(obj))
@@ -727,8 +731,10 @@ describe('api', () => {
expect(result).toEqual(obj)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/id1/trash')
expect(url).toContain('/api/objects/id1')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ action: 'trash' })
})
it('throws on error response', async () => {
@@ -739,7 +745,7 @@ describe('api', () => {
})
describe('batchTrashObjects', () => {
it('posts ids to batch trash endpoint and returns trashed count', async () => {
it('patches batch endpoint with action: trash and returns trashed count', async () => {
const payload = { trashed: 3 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
@@ -747,10 +753,10 @@ describe('api', () => {
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/batch/trash')
expect(init.method).toBe('POST')
expect(url).toContain('/api/objects/batch')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ ids: ['id1', 'id2', 'id3'] })
expect(body).toMatchObject({ action: 'trash', ids: ['id1', 'id2', 'id3'] })
})
it('posts an empty ids array without error', async () => {
@@ -761,7 +767,7 @@ describe('api', () => {
expect(result).toEqual({ trashed: 0 })
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ ids: [] })
expect(body).toMatchObject({ action: 'trash', ids: [] })
})
it('throws on error response', async () => {
@@ -772,7 +778,7 @@ describe('api', () => {
})
describe('batchMoveObjects', () => {
it('posts ids and parent to batch move endpoint and returns moved count', async () => {
it('patches batch endpoint with action: move and returns moved count', async () => {
const payload = { moved: 2 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
@@ -780,10 +786,10 @@ describe('api', () => {
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/batch/move')
expect(init.method).toBe('POST')
expect(url).toContain('/api/objects/batch')
expect(init.method).toBe('PATCH')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ ids: ['id1', 'id2'], parent: 'folder1' })
expect(body).toMatchObject({ action: 'move', ids: ['id1', 'id2'], parent: 'folder1' })
})
it('throws on error response', async () => {
@@ -794,7 +800,7 @@ describe('api', () => {
})
describe('batchDeleteObjects', () => {
it('posts ids to batch delete endpoint and returns deleted count', async () => {
it('sends DELETE to batch endpoint and returns deleted count', async () => {
const payload = { deleted: 2 }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
@@ -802,8 +808,8 @@ describe('api', () => {
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/objects/batch/delete')
expect(init.method).toBe('POST')
expect(url).toContain('/api/objects/batch')
expect(init.method).toBe('DELETE')
const body = typeof init.body === 'string' ? JSON.parse(init.body) : null
expect(body).toMatchObject({ ids: ['id1', 'id2'] })
})
@@ -945,14 +951,14 @@ describe('api', () => {
})
describe('getUnreadCount', () => {
it('calls /api/notifications/unread-count and returns count', async () => {
it('calls /api/notifications/stats and returns count', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 3 }))
const result = await getUnreadCount()
expect(result).toEqual({ count: 3 })
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
expect(url).toContain('/api/notifications/unread-count')
expect(url).toContain('/api/notifications/stats')
})
it('throws on error response', async () => {
@@ -963,13 +969,13 @@ describe('api', () => {
})
describe('markNotificationRead', () => {
it('posts to /api/notifications/:id/read and resolves on 204', async () => {
it('patches /api/notifications/:id and resolves on 204', async () => {
vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 204 } as Response)
await expect(markNotificationRead('notif-1')).resolves.toBeUndefined()
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/notifications/notif-1/read')
expect(init.method).toBe('POST')
expect(url).toContain('/api/notifications/notif-1')
expect(init.method).toBe('PATCH')
})
it('throws ApiError on non-ok response', async () => {
@@ -980,15 +986,15 @@ describe('api', () => {
})
describe('markAllNotificationsRead', () => {
it('posts to /api/notifications/read-all and returns count', async () => {
it('patches /api/notifications and returns count', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ count: 5 }))
const result = await markAllNotificationsRead()
expect(result).toEqual({ count: 5 })
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/notifications/read-all')
expect(init.method).toBe('POST')
expect(url).toContain('/api/notifications')
expect(init.method).toBe('PATCH')
})
it('throws on error response', async () => {
+22 -17
View File
@@ -11,6 +11,7 @@ import type {
StorageObject,
} from '@shared/types'
import {
adminAuthProviders,
adminQuotas,
authedSharesApi,
authProviders,
@@ -108,11 +109,13 @@ export function createObject(data: {
}
export function updateObject(id: string, data: { name?: string; parent?: string; onConflict?: ConflictStrategy }) {
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: data }))
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: { action: 'update' as const, ...data } }))
}
export function confirmUpload(id: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(objects[':id'].done.$patch({ param: { id }, json: { onConflict } }))
return unwrap<StorageObject>(
objects[':id'].$patch({ param: { id }, json: { action: 'confirm' as const, onConflict } }),
)
}
export function deleteObject(id: string) {
@@ -120,31 +123,33 @@ export function deleteObject(id: string) {
}
export function copyObject(id: string, parent: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(objects[':id'].copy.$post({ param: { id }, json: { parent, onConflict } }))
return unwrap<StorageObject>(objects.copy.$post({ json: { copyFrom: id, parent, onConflict } }))
}
export function trashObject(id: string) {
return unwrap<StorageObject>(objects[':id'].trash.$patch({ param: { id } }))
return unwrap<StorageObject>(objects[':id'].$patch({ param: { id }, json: { action: 'trash' as const } }))
}
export function restoreObject(id: string, onConflict?: ConflictStrategy) {
return unwrap<StorageObject>(objects[':id'].restore.$patch({ param: { id }, json: { onConflict } }))
return unwrap<StorageObject>(
objects[':id'].$patch({ param: { id }, json: { action: 'restore' as const, onConflict } }),
)
}
export function batchMoveObjects(ids: string[], parent: string, onConflict?: ConflictStrategy) {
return unwrap<{ moved: number }>(objects.batch.move.$post({ json: { ids, parent, onConflict } }))
return unwrap<{ moved: number }>(objects.batch.$patch({ json: { action: 'move' as const, ids, parent, onConflict } }))
}
export function batchTrashObjects(ids: string[]) {
return unwrap<{ trashed: number }>(objects.batch.trash.$post({ json: { ids } }))
return unwrap<{ trashed: number }>(objects.batch.$patch({ json: { action: 'trash' as const, ids } }))
}
export function batchDeleteObjects(ids: string[]) {
return unwrap<{ deleted: number }>(objects.batch.delete.$post({ json: { ids } }))
return unwrap<{ deleted: number }>(objects.batch.$delete({ json: { ids } }))
}
export function emptyTrash() {
return unwrap<{ purged: number }>(trash.empty.$post())
return unwrap<{ purged: number }>(trash.index.$delete())
}
// Admin Storages API
@@ -190,7 +195,7 @@ export function listUsers(page: number, pageSize: number) {
}
export function updateUserStatus(userId: string, status: 'active' | 'disabled') {
return unwrap<{ id: string; status: string }>(users[':id'].status.$put({ param: { id: userId }, json: { status } }))
return unwrap<{ id: string; status: string }>(users[':id'].$patch({ param: { id: userId }, json: { status } }))
}
export function deleteUser(userId: string) {
@@ -250,16 +255,16 @@ export function listAuthProviders() {
}
export function listAdminAuthProviders() {
return unwrap<{ items: OAuthProviderConfig[] }>(authProviders.admin.$get())
return unwrap<{ items: OAuthProviderConfig[] }>(adminAuthProviders.index.$get())
}
export function upsertAuthProvider(providerId: string, data: Omit<OAuthProviderConfig, 'providerId'>) {
return unwrap<OAuthProviderConfig>(authProviders.admin[':providerId'].$put({ param: { providerId }, json: data }))
return unwrap<OAuthProviderConfig>(adminAuthProviders[':providerId'].$put({ param: { providerId }, json: data }))
}
export function deleteAuthProvider(providerId: string) {
return unwrap<{ providerId: string; deleted: boolean }>(
authProviders.admin[':providerId'].$delete({ param: { providerId } }),
adminAuthProviders[':providerId'].$delete({ param: { providerId } }),
)
}
@@ -314,7 +319,7 @@ export function saveEmailConfig(data: EmailConfigData) {
}
export function testEmail(to: string) {
return unwrap<{ success: boolean; error?: string }>(emailConfig.test.$post({ json: { to } }))
return unwrap<{ success: boolean; error?: string }>(emailConfig['test-messages'].$post({ json: { to } }))
}
// Profile API (public, no auth)
@@ -360,17 +365,17 @@ export function listNotifications(page = 1, pageSize = 20, unreadOnly = false) {
}
export function getUnreadCount() {
return unwrap<{ count: number }>(notificationsApi['unread-count'].$get())
return unwrap<{ count: number }>(notificationsApi.stats.$get())
}
export function markNotificationRead(id: string) {
return notificationsApi[':id'].read.$post({ param: { id } }).then((res) => {
return notificationsApi[':id'].$patch({ param: { id } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, { error: res.statusText })
})
}
export function markAllNotificationsRead() {
return unwrap<{ count: number }>(notificationsApi['read-all'].$post())
return unwrap<{ count: number }>(notificationsApi.index.$patch())
}
// Shares API
+3 -1
View File
@@ -1,4 +1,5 @@
import type {
AdminAuthProvidersRoute,
AdminInviteCodesRoute,
AdminQuotasRoute,
AuthedSharesRoute,
@@ -21,13 +22,14 @@ import { hc } from 'hono/client'
const opts = { init: { credentials: 'include' as RequestCredentials } }
export const objects = hc<ObjectsRoute>('/api/objects', opts)
export const trash = hc<TrashRoute>('/api/recycle-bin', opts)
export const trash = hc<TrashRoute>('/api/trash', opts)
export const storages = hc<StoragesRoute>('/api/admin/storages', opts)
export const users = hc<UsersRoute>('/api/admin/users', opts)
export const adminQuotas = hc<AdminQuotasRoute>('/api/admin/quotas', opts)
export const userQuotas = hc<UserQuotasRoute>('/api/quotas', opts)
export const system = hc<SystemRoute>('/api/system', opts)
export const authProviders = hc<AuthProvidersRoute>('/api/auth-providers', opts)
export const adminAuthProviders = hc<AdminAuthProvidersRoute>('/api/admin/auth-providers', opts)
export const inviteCodes = hc<AdminInviteCodesRoute>('/api/admin/invite-codes', opts)
export const emailConfig = hc<EmailConfigRoute>('/api/admin/email-config', opts)
export const profiles = hc<ProfileRoute>('/api/profiles')
+21 -22
View File
@@ -19,11 +19,11 @@ import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
import { Route as AuthenticatedAdminRouteRouteImport } from './routes/_authenticated/admin/route'
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
import { Route as AuthenticatedTrashIndexRouteImport } from './routes/_authenticated/trash/index'
import { Route as AuthenticatedTeamsIndexRouteImport } from './routes/_authenticated/teams/index'
import { Route as AuthenticatedStoragesIndexRouteImport } from './routes/_authenticated/storages/index'
import { Route as AuthenticatedSharesIndexRouteImport } from './routes/_authenticated/shares/index'
import { Route as AuthenticatedSettingsIndexRouteImport } from './routes/_authenticated/settings/index'
import { Route as AuthenticatedRecycleBinIndexRouteImport } from './routes/_authenticated/recycle-bin/index'
import { Route as AuthenticatedFilesIndexRouteImport } from './routes/_authenticated/files/index'
import { Route as AuthenticatedTeamsInviteRouteImport } from './routes/_authenticated/teams/invite'
import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_authenticated/settings/profile'
@@ -89,6 +89,11 @@ const AuthenticatedUsersIndexRoute = AuthenticatedUsersIndexRouteImport.update({
path: '/users/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTrashIndexRoute = AuthenticatedTrashIndexRouteImport.update({
id: '/trash/',
path: '/trash/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTeamsIndexRoute = AuthenticatedTeamsIndexRouteImport.update({
id: '/teams/',
path: '/teams/',
@@ -112,12 +117,6 @@ const AuthenticatedSettingsIndexRoute =
path: '/',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedRecycleBinIndexRoute =
AuthenticatedRecycleBinIndexRouteImport.update({
id: '/recycle-bin/',
path: '/recycle-bin/',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedFilesIndexRoute = AuthenticatedFilesIndexRouteImport.update({
id: '/files/',
path: '/files/',
@@ -217,11 +216,11 @@ export interface FileRoutesByFullPath {
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/teams/invite': typeof AuthenticatedTeamsInviteRoute
'/files/': typeof AuthenticatedFilesIndexRoute
'/recycle-bin/': typeof AuthenticatedRecycleBinIndexRoute
'/settings/': typeof AuthenticatedSettingsIndexRoute
'/shares/': typeof AuthenticatedSharesIndexRoute
'/storages/': typeof AuthenticatedStoragesIndexRoute
'/teams/': typeof AuthenticatedTeamsIndexRoute
'/trash/': typeof AuthenticatedTrashIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
@@ -245,11 +244,11 @@ export interface FileRoutesByTo {
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/teams/invite': typeof AuthenticatedTeamsInviteRoute
'/files': typeof AuthenticatedFilesIndexRoute
'/recycle-bin': typeof AuthenticatedRecycleBinIndexRoute
'/settings': typeof AuthenticatedSettingsIndexRoute
'/shares': typeof AuthenticatedSharesIndexRoute
'/storages': typeof AuthenticatedStoragesIndexRoute
'/teams': typeof AuthenticatedTeamsIndexRoute
'/trash': typeof AuthenticatedTrashIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
@@ -277,11 +276,11 @@ export interface FileRoutesById {
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileRoute
'/_authenticated/teams/invite': typeof AuthenticatedTeamsInviteRoute
'/_authenticated/files/': typeof AuthenticatedFilesIndexRoute
'/_authenticated/recycle-bin/': typeof AuthenticatedRecycleBinIndexRoute
'/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute
'/_authenticated/shares/': typeof AuthenticatedSharesIndexRoute
'/_authenticated/storages/': typeof AuthenticatedStoragesIndexRoute
'/_authenticated/teams/': typeof AuthenticatedTeamsIndexRoute
'/_authenticated/trash/': typeof AuthenticatedTrashIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
@@ -309,11 +308,11 @@ export interface FileRouteTypes {
| '/settings/profile'
| '/teams/invite'
| '/files/'
| '/recycle-bin/'
| '/settings/'
| '/shares/'
| '/storages/'
| '/teams/'
| '/trash/'
| '/users/'
| '/admin/settings/auth'
| '/teams/$teamId/activity'
@@ -337,11 +336,11 @@ export interface FileRouteTypes {
| '/settings/profile'
| '/teams/invite'
| '/files'
| '/recycle-bin'
| '/settings'
| '/shares'
| '/storages'
| '/teams'
| '/trash'
| '/users'
| '/admin/settings/auth'
| '/teams/$teamId/activity'
@@ -368,11 +367,11 @@ export interface FileRouteTypes {
| '/_authenticated/settings/profile'
| '/_authenticated/teams/invite'
| '/_authenticated/files/'
| '/_authenticated/recycle-bin/'
| '/_authenticated/settings/'
| '/_authenticated/shares/'
| '/_authenticated/storages/'
| '/_authenticated/teams/'
| '/_authenticated/trash/'
| '/_authenticated/users/'
| '/_authenticated/admin/settings/auth'
| '/_authenticated/teams/$teamId/activity'
@@ -464,6 +463,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedUsersIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/trash/': {
id: '/_authenticated/trash/'
path: '/trash'
fullPath: '/trash/'
preLoaderRoute: typeof AuthenticatedTrashIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/teams/': {
id: '/_authenticated/teams/'
path: '/teams'
@@ -492,13 +498,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsIndexRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/recycle-bin/': {
id: '/_authenticated/recycle-bin/'
path: '/recycle-bin'
fullPath: '/recycle-bin/'
preLoaderRoute: typeof AuthenticatedRecycleBinIndexRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/files/': {
id: '/_authenticated/files/'
path: '/files'
@@ -669,10 +668,10 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedTeamsTeamIdRouteRoute: typeof AuthenticatedTeamsTeamIdRouteRouteWithChildren
AuthenticatedTeamsInviteRoute: typeof AuthenticatedTeamsInviteRoute
AuthenticatedFilesIndexRoute: typeof AuthenticatedFilesIndexRoute
AuthenticatedRecycleBinIndexRoute: typeof AuthenticatedRecycleBinIndexRoute
AuthenticatedSharesIndexRoute: typeof AuthenticatedSharesIndexRoute
AuthenticatedStoragesIndexRoute: typeof AuthenticatedStoragesIndexRoute
AuthenticatedTeamsIndexRoute: typeof AuthenticatedTeamsIndexRoute
AuthenticatedTrashIndexRoute: typeof AuthenticatedTrashIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
}
@@ -684,10 +683,10 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedTeamsTeamIdRouteRouteWithChildren,
AuthenticatedTeamsInviteRoute: AuthenticatedTeamsInviteRoute,
AuthenticatedFilesIndexRoute: AuthenticatedFilesIndexRoute,
AuthenticatedRecycleBinIndexRoute: AuthenticatedRecycleBinIndexRoute,
AuthenticatedSharesIndexRoute: AuthenticatedSharesIndexRoute,
AuthenticatedStoragesIndexRoute: AuthenticatedStoragesIndexRoute,
AuthenticatedTeamsIndexRoute: AuthenticatedTeamsIndexRoute,
AuthenticatedTrashIndexRoute: AuthenticatedTrashIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
}
+3 -1
View File
@@ -38,7 +38,9 @@ function TeamInvitePage() {
const joinMutation = useMutation({
mutationFn: async () => {
const res = await teamsApi.join.$post({ json: { token } })
const teamId = info?.organizationId
if (!teamId) throw new Error('Team info not loaded')
const res = await teamsApi[':teamId'].members.$post({ param: { teamId }, json: { token } })
if (!res.ok) {
const body = await res.json()
throw new Error((body as { error?: string }).error ?? 'Failed to join')
@@ -20,14 +20,14 @@ import {
} from '@/components/ui/dialog'
import { deleteObject, emptyTrash, listObjects, restoreObject } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/recycle-bin/')({
component: RecycleBinPage,
export const Route = createFileRoute('/_authenticated/trash/')({
component: TrashPage,
})
const QUERY_KEY = ['objects', 'trashed']
const PAGE_SIZE = 20
function RecycleBinPage() {
function TrashPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [page, setPage] = useState(1)