mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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' }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }),
|
||||
})
|
||||
|
||||
@@ -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
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
+3
-3
@@ -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)
|
||||
Reference in New Issue
Block a user