happy2 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/package.json +74 -0
- package/packages/happy2-server/Dockerfile +18 -0
- package/packages/happy2-server/README.md +418 -0
- package/packages/happy2-server/bin/happy2.js +3 -0
- package/packages/happy2-server/dist/cli.d.ts +3 -0
- package/packages/happy2-server/dist/cli.d.ts.map +1 -0
- package/packages/happy2-server/dist/cli.js +44 -0
- package/packages/happy2-server/dist/cli.js.map +1 -0
- package/packages/happy2-server/dist/index.d.ts +24 -0
- package/packages/happy2-server/dist/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/index.js +22 -0
- package/packages/happy2-server/dist/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/builtin-images.d.ts +8 -0
- package/packages/happy2-server/dist/modules/agents/builtin-images.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/builtin-images.js +283 -0
- package/packages/happy2-server/dist/modules/agents/builtin-images.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/command.d.ts +3 -0
- package/packages/happy2-server/dist/modules/agents/command.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/command.js +7 -0
- package/packages/happy2-server/dist/modules/agents/command.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/daemon.d.ts +106 -0
- package/packages/happy2-server/dist/modules/agents/daemon.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/daemon.js +522 -0
- package/packages/happy2-server/dist/modules/agents/daemon.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/docker.d.ts +51 -0
- package/packages/happy2-server/dist/modules/agents/docker.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/docker.js +256 -0
- package/packages/happy2-server/dist/modules/agents/docker.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/docker.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/agents/docker.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/docker.test.js +155 -0
- package/packages/happy2-server/dist/modules/agents/docker.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/index.d.ts +5 -0
- package/packages/happy2-server/dist/modules/agents/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/index.js +4 -0
- package/packages/happy2-server/dist/modules/agents/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/agents/service.d.ts +140 -0
- package/packages/happy2-server/dist/modules/agents/service.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/agents/service.js +1283 -0
- package/packages/happy2-server/dist/modules/agents/service.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/cloudflare-access.d.ts +15 -0
- package/packages/happy2-server/dist/modules/auth/cloudflare-access.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/cloudflare-access.js +49 -0
- package/packages/happy2-server/dist/modules/auth/cloudflare-access.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/crypto.d.ts +4 -0
- package/packages/happy2-server/dist/modules/auth/crypto.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/crypto.js +31 -0
- package/packages/happy2-server/dist/modules/auth/crypto.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/crypto.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/auth/crypto.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/crypto.test.js +11 -0
- package/packages/happy2-server/dist/modules/auth/crypto.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/email.d.ts +3 -0
- package/packages/happy2-server/dist/modules/auth/email.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/email.js +22 -0
- package/packages/happy2-server/dist/modules/auth/email.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/metadata.d.ts +5 -0
- package/packages/happy2-server/dist/modules/auth/metadata.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/metadata.js +28 -0
- package/packages/happy2-server/dist/modules/auth/metadata.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/metadata.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/auth/metadata.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/metadata.test.js +10 -0
- package/packages/happy2-server/dist/modules/auth/metadata.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/methods.d.ts +10 -0
- package/packages/happy2-server/dist/modules/auth/methods.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/methods.js +21 -0
- package/packages/happy2-server/dist/modules/auth/methods.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/methods.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/auth/methods.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/methods.test.js +16 -0
- package/packages/happy2-server/dist/modules/auth/methods.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/oidc.d.ts +9 -0
- package/packages/happy2-server/dist/modules/auth/oidc.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/oidc.js +68 -0
- package/packages/happy2-server/dist/modules/auth/oidc.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/service.d.ts +44 -0
- package/packages/happy2-server/dist/modules/auth/service.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/service.js +208 -0
- package/packages/happy2-server/dist/modules/auth/service.js.map +1 -0
- package/packages/happy2-server/dist/modules/auth/tokens.d.ts +21 -0
- package/packages/happy2-server/dist/modules/auth/tokens.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/auth/tokens.js +79 -0
- package/packages/happy2-server/dist/modules/auth/tokens.js.map +1 -0
- package/packages/happy2-server/dist/modules/automation/repository.d.ts +127 -0
- package/packages/happy2-server/dist/modules/automation/repository.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/automation/repository.js +821 -0
- package/packages/happy2-server/dist/modules/automation/repository.js.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.d.ts +839 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.js +5795 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.js.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.test.js +620 -0
- package/packages/happy2-server/dist/modules/collaboration/repository.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/types.d.ts +243 -0
- package/packages/happy2-server/dist/modules/collaboration/types.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/collaboration/types.js +8 -0
- package/packages/happy2-server/dist/modules/collaboration/types.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/defaults.d.ts +8 -0
- package/packages/happy2-server/dist/modules/config/defaults.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/defaults.js +70 -0
- package/packages/happy2-server/dist/modules/config/defaults.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/defaults.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/config/defaults.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/defaults.test.js +27 -0
- package/packages/happy2-server/dist/modules/config/defaults.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/environment.d.ts +4 -0
- package/packages/happy2-server/dist/modules/config/environment.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/environment.js +92 -0
- package/packages/happy2-server/dist/modules/config/environment.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/loader.d.ts +5 -0
- package/packages/happy2-server/dist/modules/config/loader.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/loader.js +251 -0
- package/packages/happy2-server/dist/modules/config/loader.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/loader.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/config/loader.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/loader.test.js +108 -0
- package/packages/happy2-server/dist/modules/config/loader.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/paths.d.ts +7 -0
- package/packages/happy2-server/dist/modules/config/paths.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/paths.js +15 -0
- package/packages/happy2-server/dist/modules/config/paths.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/paths.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/config/paths.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/paths.test.js +22 -0
- package/packages/happy2-server/dist/modules/config/paths.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/runtime.d.ts +6 -0
- package/packages/happy2-server/dist/modules/config/runtime.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/runtime.js +14 -0
- package/packages/happy2-server/dist/modules/config/runtime.js.map +1 -0
- package/packages/happy2-server/dist/modules/config/type.d.ts +88 -0
- package/packages/happy2-server/dist/modules/config/type.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/config/type.js +2 -0
- package/packages/happy2-server/dist/modules/config/type.js.map +1 -0
- package/packages/happy2-server/dist/modules/database.d.ts +105 -0
- package/packages/happy2-server/dist/modules/database.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/database.js +583 -0
- package/packages/happy2-server/dist/modules/database.js.map +1 -0
- package/packages/happy2-server/dist/modules/drizzle.d.ts +11 -0
- package/packages/happy2-server/dist/modules/drizzle.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/drizzle.js +31 -0
- package/packages/happy2-server/dist/modules/drizzle.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/lock.d.ts +3 -0
- package/packages/happy2-server/dist/modules/files/lock.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/lock.js +83 -0
- package/packages/happy2-server/dist/modules/files/lock.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/media.d.ts +18 -0
- package/packages/happy2-server/dist/modules/files/media.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/media.js +352 -0
- package/packages/happy2-server/dist/modules/files/media.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/provider.d.ts +130 -0
- package/packages/happy2-server/dist/modules/files/provider.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/provider.js +448 -0
- package/packages/happy2-server/dist/modules/files/provider.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/quota.d.ts +58 -0
- package/packages/happy2-server/dist/modules/files/quota.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/quota.js +180 -0
- package/packages/happy2-server/dist/modules/files/quota.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/routes.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/files/routes.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/routes.test.js +163 -0
- package/packages/happy2-server/dist/modules/files/routes.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/scanner.d.ts +27 -0
- package/packages/happy2-server/dist/modules/files/scanner.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/scanner.js +66 -0
- package/packages/happy2-server/dist/modules/files/scanner.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/storage.d.ts +95 -0
- package/packages/happy2-server/dist/modules/files/storage.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/storage.js +855 -0
- package/packages/happy2-server/dist/modules/files/storage.js.map +1 -0
- package/packages/happy2-server/dist/modules/files/storage.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/files/storage.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/files/storage.test.js +170 -0
- package/packages/happy2-server/dist/modules/files/storage.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/index.d.ts +6 -0
- package/packages/happy2-server/dist/modules/integrations/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/index.js +6 -0
- package/packages/happy2-server/dist/modules/integrations/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/repository.d.ts +136 -0
- package/packages/happy2-server/dist/modules/integrations/repository.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/repository.js +1229 -0
- package/packages/happy2-server/dist/modules/integrations/repository.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/repository.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/integrations/repository.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/repository.test.js +345 -0
- package/packages/happy2-server/dist/modules/integrations/repository.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/routes.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/integrations/routes.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/routes.test.js +161 -0
- package/packages/happy2-server/dist/modules/integrations/routes.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/secrets.d.ts +19 -0
- package/packages/happy2-server/dist/modules/integrations/secrets.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/secrets.js +69 -0
- package/packages/happy2-server/dist/modules/integrations/secrets.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.d.ts +32 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.js +185 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.test.js +56 -0
- package/packages/happy2-server/dist/modules/integrations/ssrf.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/transport.d.ts +21 -0
- package/packages/happy2-server/dist/modules/integrations/transport.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/transport.js +108 -0
- package/packages/happy2-server/dist/modules/integrations/transport.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/transport.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/integrations/transport.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/transport.test.js +66 -0
- package/packages/happy2-server/dist/modules/integrations/transport.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/types.d.ts +155 -0
- package/packages/happy2-server/dist/modules/integrations/types.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/integrations/types.js +26 -0
- package/packages/happy2-server/dist/modules/integrations/types.js.map +1 -0
- package/packages/happy2-server/dist/modules/operations/export-worker.d.ts +16 -0
- package/packages/happy2-server/dist/modules/operations/export-worker.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/operations/export-worker.js +56 -0
- package/packages/happy2-server/dist/modules/operations/export-worker.js.map +1 -0
- package/packages/happy2-server/dist/modules/operations/repository.d.ts +215 -0
- package/packages/happy2-server/dist/modules/operations/repository.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/operations/repository.js +2011 -0
- package/packages/happy2-server/dist/modules/operations/repository.js.map +1 -0
- package/packages/happy2-server/dist/modules/operations/repository.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/operations/repository.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/operations/repository.test.js +610 -0
- package/packages/happy2-server/dist/modules/operations/repository.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/operations/types.d.ts +144 -0
- package/packages/happy2-server/dist/modules/operations/types.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/operations/types.js +9 -0
- package/packages/happy2-server/dist/modules/operations/types.js.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/events.d.ts +99 -0
- package/packages/happy2-server/dist/modules/realtime/events.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/events.js +173 -0
- package/packages/happy2-server/dist/modules/realtime/events.js.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/index.d.ts +4 -0
- package/packages/happy2-server/dist/modules/realtime/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/index.js +4 -0
- package/packages/happy2-server/dist/modules/realtime/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/local.d.ts +37 -0
- package/packages/happy2-server/dist/modules/realtime/local.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/local.js +181 -0
- package/packages/happy2-server/dist/modules/realtime/local.js.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/local.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/realtime/local.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/local.test.js +186 -0
- package/packages/happy2-server/dist/modules/realtime/local.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/pubsub.d.ts +26 -0
- package/packages/happy2-server/dist/modules/realtime/pubsub.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/realtime/pubsub.js +7 -0
- package/packages/happy2-server/dist/modules/realtime/pubsub.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/database-idempotency.d.ts +15 -0
- package/packages/happy2-server/dist/modules/request/database-idempotency.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/database-idempotency.js +141 -0
- package/packages/happy2-server/dist/modules/request/database-idempotency.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency-hook.d.ts +12 -0
- package/packages/happy2-server/dist/modules/request/idempotency-hook.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency-hook.js +98 -0
- package/packages/happy2-server/dist/modules/request/idempotency-hook.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency.d.ts +146 -0
- package/packages/happy2-server/dist/modules/request/idempotency.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency.js +312 -0
- package/packages/happy2-server/dist/modules/request/idempotency.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/request/idempotency.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/idempotency.test.js +191 -0
- package/packages/happy2-server/dist/modules/request/idempotency.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/index.d.ts +5 -0
- package/packages/happy2-server/dist/modules/request/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/index.js +5 -0
- package/packages/happy2-server/dist/modules/request/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.d.ts +100 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.js +239 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.js.map +1 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.test.d.ts +2 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.test.js +123 -0
- package/packages/happy2-server/dist/modules/request/rate-limit.test.js.map +1 -0
- package/packages/happy2-server/dist/modules/schema.d.ts +31454 -0
- package/packages/happy2-server/dist/modules/schema.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/schema.js +1081 -0
- package/packages/happy2-server/dist/modules/schema.js.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/index.d.ts +3 -0
- package/packages/happy2-server/dist/modules/workspace/index.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/index.js +3 -0
- package/packages/happy2-server/dist/modules/workspace/index.js.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/service.d.ts +54 -0
- package/packages/happy2-server/dist/modules/workspace/service.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/service.js +997 -0
- package/packages/happy2-server/dist/modules/workspace/service.js.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/types.d.ts +58 -0
- package/packages/happy2-server/dist/modules/workspace/types.d.ts.map +1 -0
- package/packages/happy2-server/dist/modules/workspace/types.js +11 -0
- package/packages/happy2-server/dist/modules/workspace/types.js.map +1 -0
- package/packages/happy2-server/dist/routes/agents.d.ts +5 -0
- package/packages/happy2-server/dist/routes/agents.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/agents.js +225 -0
- package/packages/happy2-server/dist/routes/agents.js.map +1 -0
- package/packages/happy2-server/dist/routes/auth.d.ts +5 -0
- package/packages/happy2-server/dist/routes/auth.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/auth.js +128 -0
- package/packages/happy2-server/dist/routes/auth.js.map +1 -0
- package/packages/happy2-server/dist/routes/automation.d.ts +6 -0
- package/packages/happy2-server/dist/routes/automation.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/automation.js +315 -0
- package/packages/happy2-server/dist/routes/automation.js.map +1 -0
- package/packages/happy2-server/dist/routes/basic.d.ts +3 -0
- package/packages/happy2-server/dist/routes/basic.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/basic.js +5 -0
- package/packages/happy2-server/dist/routes/basic.js.map +1 -0
- package/packages/happy2-server/dist/routes/collaboration.d.ts +7 -0
- package/packages/happy2-server/dist/routes/collaboration.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/collaboration.js +1285 -0
- package/packages/happy2-server/dist/routes/collaboration.js.map +1 -0
- package/packages/happy2-server/dist/routes/files.d.ts +9 -0
- package/packages/happy2-server/dist/routes/files.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/files.js +397 -0
- package/packages/happy2-server/dist/routes/files.js.map +1 -0
- package/packages/happy2-server/dist/routes/integrations.d.ts +6 -0
- package/packages/happy2-server/dist/routes/integrations.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/integrations.js +486 -0
- package/packages/happy2-server/dist/routes/integrations.js.map +1 -0
- package/packages/happy2-server/dist/routes/operations.d.ts +5 -0
- package/packages/happy2-server/dist/routes/operations.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/operations.js +588 -0
- package/packages/happy2-server/dist/routes/operations.js.map +1 -0
- package/packages/happy2-server/dist/routes/operations.test.d.ts +2 -0
- package/packages/happy2-server/dist/routes/operations.test.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/operations.test.js +106 -0
- package/packages/happy2-server/dist/routes/operations.test.js.map +1 -0
- package/packages/happy2-server/dist/routes/sync.d.ts +6 -0
- package/packages/happy2-server/dist/routes/sync.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/sync.js +549 -0
- package/packages/happy2-server/dist/routes/sync.js.map +1 -0
- package/packages/happy2-server/dist/routes/workspace.d.ts +5 -0
- package/packages/happy2-server/dist/routes/workspace.d.ts.map +1 -0
- package/packages/happy2-server/dist/routes/workspace.js +240 -0
- package/packages/happy2-server/dist/routes/workspace.js.map +1 -0
- package/packages/happy2-server/dist/runner.d.ts +3 -0
- package/packages/happy2-server/dist/runner.d.ts.map +1 -0
- package/packages/happy2-server/dist/runner.js +30 -0
- package/packages/happy2-server/dist/runner.js.map +1 -0
- package/packages/happy2-server/dist/server.d.ts +32 -0
- package/packages/happy2-server/dist/server.d.ts.map +1 -0
- package/packages/happy2-server/dist/server.js +284 -0
- package/packages/happy2-server/dist/server.js.map +1 -0
- package/packages/happy2-server/dist/standalone.d.ts +13 -0
- package/packages/happy2-server/dist/standalone.d.ts.map +1 -0
- package/packages/happy2-server/dist/standalone.js +99 -0
- package/packages/happy2-server/dist/standalone.js.map +1 -0
- package/packages/happy2-server/dist/web/assets/figtree-latin-wght-normal-D_ZTVpCC.woff2 +0 -0
- package/packages/happy2-server/dist/web/assets/happy-otter-Q2O_g-DT.png +0 -0
- package/packages/happy2-server/dist/web/assets/index-COvbX8JP.css +2 -0
- package/packages/happy2-server/dist/web/assets/index-DdpugxMO.js +57 -0
- package/packages/happy2-server/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/packages/happy2-server/dist/web/assets/onboarding-CRUpq9xl.png +0 -0
- package/packages/happy2-server/dist/web/index.html +13 -0
- package/packages/happy2-server/drizzle/0000_initial.sql +13 -0
- package/packages/happy2-server/drizzle/0001_accounts_profiles_files.sql +42 -0
- package/packages/happy2-server/drizzle/0002_file_visibility_and_uploader.sql +5 -0
- package/packages/happy2-server/drizzle/0003_collaboration.sql +565 -0
- package/packages/happy2-server/drizzle/0004_collaboration_expansion.sql +1099 -0
- package/packages/happy2-server/drizzle/0005_ai_agents.sql +85 -0
- package/packages/happy2-server/drizzle/0006_rig_global_event_queue.sql +10 -0
- package/packages/happy2-server/drizzle/0007_rename_server_identity.sql +3 -0
- package/packages/happy2-server/drizzle/0008_agent_message_streaming.sql +47 -0
- package/packages/happy2-server/drizzle/0009_chat_workspace_binding_lookup.sql +2 -0
- package/packages/happy2-server/drizzle/0010_rig_secret_assignments.sql +21 -0
- package/packages/happy2-server/drizzle/meta/_journal.json +83 -0
- package/packages/happy2-server/happy2.example.toml +104 -0
|
@@ -0,0 +1,2011 @@
|
|
|
1
|
+
import { createClient } from "@libsql/client";
|
|
2
|
+
import { createId } from "@paralleldrive/cuid2";
|
|
3
|
+
import { and, asc, desc, eq, gt, isNull, lt, lte, or, sql } from "drizzle-orm";
|
|
4
|
+
import { createDatabase, retrySqliteBusy, } from "../drizzle.js";
|
|
5
|
+
import { accountBans, accounts, auditLogEntries, authSessionEvents, authSessions, backupRecords, chatMembers, chatUpdates, chats, dataExportJobs, fileAccessGrants, files, messageAttachments, messageRevisions, messageSearchDocuments, messages, moderationActions, moderationReports, notifications, retentionRuns, serverSyncState, syncEvents, threadParticipants, threadUserStates, threads, users, } from "../schema.js";
|
|
6
|
+
import { OperationsError, } from "./types.js";
|
|
7
|
+
const auditSelection = {
|
|
8
|
+
id: auditLogEntries.id,
|
|
9
|
+
actor_user_id: auditLogEntries.actorUserId,
|
|
10
|
+
actor_integration_id: auditLogEntries.actorIntegrationId,
|
|
11
|
+
action: auditLogEntries.action,
|
|
12
|
+
target_type: auditLogEntries.targetType,
|
|
13
|
+
target_id: auditLogEntries.targetId,
|
|
14
|
+
chat_id: auditLogEntries.chatId,
|
|
15
|
+
before_json: auditLogEntries.beforeJson,
|
|
16
|
+
after_json: auditLogEntries.afterJson,
|
|
17
|
+
metadata_json: auditLogEntries.metadataJson,
|
|
18
|
+
client_ip: auditLogEntries.clientIp,
|
|
19
|
+
device: auditLogEntries.device,
|
|
20
|
+
app_version: auditLogEntries.appVersion,
|
|
21
|
+
user_agent: auditLogEntries.userAgent,
|
|
22
|
+
created_at: auditLogEntries.createdAt,
|
|
23
|
+
};
|
|
24
|
+
const banSelection = {
|
|
25
|
+
id: accountBans.id,
|
|
26
|
+
account_id: accountBans.accountId,
|
|
27
|
+
user_id: users.id,
|
|
28
|
+
username: users.username,
|
|
29
|
+
banned_by_user_id: accountBans.bannedByUserId,
|
|
30
|
+
reason: accountBans.reason,
|
|
31
|
+
banned_at: accountBans.bannedAt,
|
|
32
|
+
expires_at: accountBans.expiresAt,
|
|
33
|
+
revoked_at: accountBans.revokedAt,
|
|
34
|
+
revoked_by_user_id: accountBans.revokedByUserId,
|
|
35
|
+
revoke_reason: accountBans.revokeReason,
|
|
36
|
+
};
|
|
37
|
+
const reportSelection = {
|
|
38
|
+
id: moderationReports.id,
|
|
39
|
+
reported_by_user_id: moderationReports.reportedByUserId,
|
|
40
|
+
target_user_id: moderationReports.targetUserId,
|
|
41
|
+
chat_id: moderationReports.chatId,
|
|
42
|
+
message_id: moderationReports.messageId,
|
|
43
|
+
file_id: moderationReports.fileId,
|
|
44
|
+
reason: moderationReports.reason,
|
|
45
|
+
details: moderationReports.details,
|
|
46
|
+
status: moderationReports.status,
|
|
47
|
+
assigned_to_user_id: moderationReports.assignedToUserId,
|
|
48
|
+
resolution: moderationReports.resolution,
|
|
49
|
+
created_at: moderationReports.createdAt,
|
|
50
|
+
updated_at: moderationReports.updatedAt,
|
|
51
|
+
resolved_at: moderationReports.resolvedAt,
|
|
52
|
+
};
|
|
53
|
+
const moderationActionSelection = {
|
|
54
|
+
id: moderationActions.id,
|
|
55
|
+
report_id: moderationActions.reportId,
|
|
56
|
+
actor_user_id: moderationActions.actorUserId,
|
|
57
|
+
target_user_id: moderationActions.targetUserId,
|
|
58
|
+
chat_id: moderationActions.chatId,
|
|
59
|
+
message_id: moderationActions.messageId,
|
|
60
|
+
file_id: moderationActions.fileId,
|
|
61
|
+
action: moderationActions.action,
|
|
62
|
+
reason: moderationActions.reason,
|
|
63
|
+
metadata_json: moderationActions.metadataJson,
|
|
64
|
+
expires_at: moderationActions.expiresAt,
|
|
65
|
+
revoked_at: moderationActions.revokedAt,
|
|
66
|
+
created_at: moderationActions.createdAt,
|
|
67
|
+
};
|
|
68
|
+
const exportSelection = {
|
|
69
|
+
id: dataExportJobs.id,
|
|
70
|
+
requested_by_user_id: dataExportJobs.requestedByUserId,
|
|
71
|
+
kind: dataExportJobs.kind,
|
|
72
|
+
target_id: dataExportJobs.targetId,
|
|
73
|
+
status: dataExportJobs.status,
|
|
74
|
+
output_file_id: dataExportJobs.outputFileId,
|
|
75
|
+
options_json: dataExportJobs.optionsJson,
|
|
76
|
+
last_error: dataExportJobs.lastError,
|
|
77
|
+
expires_at: dataExportJobs.expiresAt,
|
|
78
|
+
created_at: dataExportJobs.createdAt,
|
|
79
|
+
started_at: dataExportJobs.startedAt,
|
|
80
|
+
completed_at: dataExportJobs.completedAt,
|
|
81
|
+
};
|
|
82
|
+
const backupSelection = {
|
|
83
|
+
id: backupRecords.id,
|
|
84
|
+
storage_provider: backupRecords.storageProvider,
|
|
85
|
+
storage_key: backupRecords.storageKey,
|
|
86
|
+
checksum_sha256: backupRecords.checksumSha256,
|
|
87
|
+
size: backupRecords.size,
|
|
88
|
+
status: backupRecords.status,
|
|
89
|
+
created_by_user_id: backupRecords.createdByUserId,
|
|
90
|
+
metadata_json: backupRecords.metadataJson,
|
|
91
|
+
last_error: backupRecords.lastError,
|
|
92
|
+
created_at: backupRecords.createdAt,
|
|
93
|
+
completed_at: backupRecords.completedAt,
|
|
94
|
+
retention_until: backupRecords.retentionUntil,
|
|
95
|
+
};
|
|
96
|
+
const retentionSelection = {
|
|
97
|
+
id: retentionRuns.id,
|
|
98
|
+
scope: retentionRuns.scope,
|
|
99
|
+
status: retentionRuns.status,
|
|
100
|
+
items_examined: retentionRuns.itemsExamined,
|
|
101
|
+
items_deleted: retentionRuns.itemsDeleted,
|
|
102
|
+
details_json: retentionRuns.detailsJson,
|
|
103
|
+
last_error: retentionRuns.lastError,
|
|
104
|
+
started_at: retentionRuns.startedAt,
|
|
105
|
+
completed_at: retentionRuns.completedAt,
|
|
106
|
+
};
|
|
107
|
+
/** Durable administrative and operational state. All authorization is rechecked in SQLite. */
|
|
108
|
+
export class OperationsRepository {
|
|
109
|
+
client;
|
|
110
|
+
db;
|
|
111
|
+
ownsClient;
|
|
112
|
+
constructor(source, authToken) {
|
|
113
|
+
this.ownsClient = typeof source === "string";
|
|
114
|
+
this.client =
|
|
115
|
+
typeof source === "string" ? createClient({ url: source, authToken }) : source;
|
|
116
|
+
this.db = createDatabase(this.client);
|
|
117
|
+
}
|
|
118
|
+
close() {
|
|
119
|
+
if (this.ownsClient)
|
|
120
|
+
this.client.close();
|
|
121
|
+
}
|
|
122
|
+
async listAuditLog(input) {
|
|
123
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
124
|
+
const cursor = decodeCursor(input.before);
|
|
125
|
+
const conditions = [];
|
|
126
|
+
if (input.action)
|
|
127
|
+
conditions.push(eq(auditLogEntries.action, input.action));
|
|
128
|
+
if (input.targetType)
|
|
129
|
+
conditions.push(eq(auditLogEntries.targetType, input.targetType));
|
|
130
|
+
if (input.targetId)
|
|
131
|
+
conditions.push(eq(auditLogEntries.targetId, input.targetId));
|
|
132
|
+
if (input.auditedActorUserId)
|
|
133
|
+
conditions.push(eq(auditLogEntries.actorUserId, input.auditedActorUserId));
|
|
134
|
+
if (cursor)
|
|
135
|
+
conditions.push(cursorCondition(auditLogEntries.createdAt, auditLogEntries.id, cursor));
|
|
136
|
+
const rows = await this.db
|
|
137
|
+
.select(auditSelection)
|
|
138
|
+
.from(auditLogEntries)
|
|
139
|
+
.where(and(...conditions))
|
|
140
|
+
.orderBy(desc(auditLogEntries.createdAt), desc(auditLogEntries.id))
|
|
141
|
+
.limit(input.limit + 1);
|
|
142
|
+
return page(rows, input.limit, asAudit);
|
|
143
|
+
}
|
|
144
|
+
async applyBan(input) {
|
|
145
|
+
const expiresAt = futureTimestamp(input.expiresAt, "expiresAt");
|
|
146
|
+
return this.writeDb(async (tx) => {
|
|
147
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
148
|
+
if (input.actorUserId === input.targetUserId)
|
|
149
|
+
throw new OperationsError("forbidden", "Administrators cannot ban themselves");
|
|
150
|
+
const target = await this.accountTargetDb(tx, input.targetUserId);
|
|
151
|
+
await this.closeElapsedBan(tx, target);
|
|
152
|
+
if (target.bannedAt &&
|
|
153
|
+
(!target.banExpiresAt || Date.parse(target.banExpiresAt) > Date.now()))
|
|
154
|
+
throw new OperationsError("conflict", "User already has an active ban");
|
|
155
|
+
const id = createId();
|
|
156
|
+
await tx.insert(accountBans).values({
|
|
157
|
+
id,
|
|
158
|
+
accountId: target.accountId,
|
|
159
|
+
bannedByUserId: input.actorUserId,
|
|
160
|
+
reason: input.reason,
|
|
161
|
+
expiresAt,
|
|
162
|
+
});
|
|
163
|
+
await tx
|
|
164
|
+
.update(accounts)
|
|
165
|
+
.set({
|
|
166
|
+
bannedAt: sql `CURRENT_TIMESTAMP`,
|
|
167
|
+
banExpiresAt: expiresAt ?? null,
|
|
168
|
+
banReason: input.reason ?? null,
|
|
169
|
+
bannedByUserId: input.actorUserId,
|
|
170
|
+
})
|
|
171
|
+
.where(eq(accounts.id, target.accountId));
|
|
172
|
+
const sessions = await tx
|
|
173
|
+
.update(authSessions)
|
|
174
|
+
.set({ revokedAt: sql `CURRENT_TIMESTAMP` })
|
|
175
|
+
.where(and(eq(authSessions.accountId, target.accountId), isNull(authSessions.revokedAt)))
|
|
176
|
+
.returning({ id: authSessions.id });
|
|
177
|
+
await this.syncUserMutation(tx, input.actorUserId, target.userId, "user.banned");
|
|
178
|
+
const ban = await this.banDb(tx, id);
|
|
179
|
+
await this.appendAudit(tx, {
|
|
180
|
+
actorUserId: input.actorUserId,
|
|
181
|
+
action: "user.ban_applied",
|
|
182
|
+
targetType: "user",
|
|
183
|
+
targetId: target.userId,
|
|
184
|
+
before: accountTargetState(target),
|
|
185
|
+
after: ban,
|
|
186
|
+
context: mergeContext(input.context, {
|
|
187
|
+
revokedSessionCount: sessions.length,
|
|
188
|
+
}),
|
|
189
|
+
});
|
|
190
|
+
return ban;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
async revokeBan(input) {
|
|
194
|
+
return this.writeDb(async (tx) => {
|
|
195
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
196
|
+
const target = await this.accountTargetDb(tx, input.targetUserId);
|
|
197
|
+
const [current] = await tx
|
|
198
|
+
.select(banSelection)
|
|
199
|
+
.from(accountBans)
|
|
200
|
+
.leftJoin(users, eq(users.accountId, accountBans.accountId))
|
|
201
|
+
.where(and(eq(accountBans.accountId, target.accountId), isNull(accountBans.revokedAt)))
|
|
202
|
+
.orderBy(desc(accountBans.bannedAt), desc(accountBans.id))
|
|
203
|
+
.limit(1);
|
|
204
|
+
if (!current || !target.bannedAt)
|
|
205
|
+
throw new OperationsError("conflict", "User does not have an active ban");
|
|
206
|
+
await tx
|
|
207
|
+
.update(accountBans)
|
|
208
|
+
.set({
|
|
209
|
+
revokedAt: sql `CURRENT_TIMESTAMP`,
|
|
210
|
+
revokedByUserId: input.actorUserId,
|
|
211
|
+
revokeReason: input.reason ?? null,
|
|
212
|
+
})
|
|
213
|
+
.where(and(eq(accountBans.accountId, target.accountId), isNull(accountBans.revokedAt)));
|
|
214
|
+
await tx
|
|
215
|
+
.update(accounts)
|
|
216
|
+
.set({ bannedAt: null, banExpiresAt: null, banReason: null, bannedByUserId: null })
|
|
217
|
+
.where(eq(accounts.id, target.accountId));
|
|
218
|
+
await this.syncUserMutation(tx, input.actorUserId, target.userId, "user.unbanned");
|
|
219
|
+
const ban = await this.banDb(tx, text(current.id));
|
|
220
|
+
await this.appendAudit(tx, {
|
|
221
|
+
actorUserId: input.actorUserId,
|
|
222
|
+
action: "user.ban_revoked",
|
|
223
|
+
targetType: "user",
|
|
224
|
+
targetId: target.userId,
|
|
225
|
+
before: accountTargetState(target),
|
|
226
|
+
after: ban,
|
|
227
|
+
context: input.context,
|
|
228
|
+
});
|
|
229
|
+
return ban;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Clears elapsed bans durably. Safe for concurrent timers on multiple server instances. */
|
|
233
|
+
async expireDueBans(input) {
|
|
234
|
+
const now = new Date().toISOString();
|
|
235
|
+
const dueCondition = and(sql `${accounts.bannedAt} IS NOT NULL`, sql `${accounts.banExpiresAt} IS NOT NULL`, lte(accounts.banExpiresAt, now));
|
|
236
|
+
if (!input?.actorUserId) {
|
|
237
|
+
const [candidate] = await this.db
|
|
238
|
+
.select({ id: accounts.id })
|
|
239
|
+
.from(accounts)
|
|
240
|
+
.where(dueCondition)
|
|
241
|
+
.limit(1);
|
|
242
|
+
if (!candidate)
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
return this.writeDb(async (tx) => {
|
|
246
|
+
if (input?.actorUserId)
|
|
247
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
248
|
+
const due = await tx
|
|
249
|
+
.select({
|
|
250
|
+
accountId: accounts.id,
|
|
251
|
+
userId: users.id,
|
|
252
|
+
bannedAt: accounts.bannedAt,
|
|
253
|
+
banExpiresAt: accounts.banExpiresAt,
|
|
254
|
+
banReason: accounts.banReason,
|
|
255
|
+
bannedByUserId: accounts.bannedByUserId,
|
|
256
|
+
})
|
|
257
|
+
.from(accounts)
|
|
258
|
+
.leftJoin(users, eq(users.accountId, accounts.id))
|
|
259
|
+
.where(dueCondition);
|
|
260
|
+
for (const row of due) {
|
|
261
|
+
await tx
|
|
262
|
+
.update(accountBans)
|
|
263
|
+
.set({
|
|
264
|
+
revokedAt: sql `coalesce(${accountBans.revokedAt}, ${now})`,
|
|
265
|
+
revokeReason: sql `coalesce(${accountBans.revokeReason}, 'expired')`,
|
|
266
|
+
})
|
|
267
|
+
.where(and(eq(accountBans.accountId, row.accountId), isNull(accountBans.revokedAt), lte(accountBans.expiresAt, now)));
|
|
268
|
+
const updated = await tx
|
|
269
|
+
.update(accounts)
|
|
270
|
+
.set({
|
|
271
|
+
bannedAt: null,
|
|
272
|
+
banExpiresAt: null,
|
|
273
|
+
banReason: null,
|
|
274
|
+
bannedByUserId: null,
|
|
275
|
+
})
|
|
276
|
+
.where(and(eq(accounts.id, row.accountId), sql `${accounts.bannedAt} IS NOT NULL`, sql `${accounts.banExpiresAt} IS NOT NULL`, lte(accounts.banExpiresAt, now)))
|
|
277
|
+
.returning({ id: accounts.id });
|
|
278
|
+
if (!updated.length)
|
|
279
|
+
continue;
|
|
280
|
+
if (row.userId)
|
|
281
|
+
await this.syncUserMutation(tx, input?.actorUserId, row.userId, "user.unbanned");
|
|
282
|
+
await this.appendAudit(tx, {
|
|
283
|
+
actorUserId: input?.actorUserId,
|
|
284
|
+
action: "user.ban_expired",
|
|
285
|
+
targetType: "user",
|
|
286
|
+
targetId: row.userId ?? undefined,
|
|
287
|
+
before: {
|
|
288
|
+
bannedAt: row.bannedAt ?? undefined,
|
|
289
|
+
expiresAt: row.banExpiresAt ?? undefined,
|
|
290
|
+
reason: row.banReason ?? undefined,
|
|
291
|
+
bannedByUserId: row.bannedByUserId ?? undefined,
|
|
292
|
+
},
|
|
293
|
+
after: { banned: false },
|
|
294
|
+
context: input?.context,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return due.length;
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
async listBans(input) {
|
|
301
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
302
|
+
const cursor = decodeCursor(input.before);
|
|
303
|
+
const conditions = [];
|
|
304
|
+
if (input.targetUserId)
|
|
305
|
+
conditions.push(eq(users.id, input.targetUserId));
|
|
306
|
+
const now = new Date().toISOString();
|
|
307
|
+
if (input.status === "active") {
|
|
308
|
+
conditions.push(and(isNull(accountBans.revokedAt), or(isNull(accountBans.expiresAt), gt(accountBans.expiresAt, now))));
|
|
309
|
+
}
|
|
310
|
+
else if (input.status === "expired") {
|
|
311
|
+
conditions.push(and(isNull(accountBans.revokedAt), sql `${accountBans.expiresAt} IS NOT NULL`, lte(accountBans.expiresAt, now)));
|
|
312
|
+
}
|
|
313
|
+
else if (input.status === "revoked") {
|
|
314
|
+
conditions.push(sql `${accountBans.revokedAt} IS NOT NULL`);
|
|
315
|
+
}
|
|
316
|
+
if (cursor)
|
|
317
|
+
conditions.push(cursorCondition(accountBans.bannedAt, accountBans.id, cursor));
|
|
318
|
+
const rows = await this.db
|
|
319
|
+
.select(banSelection)
|
|
320
|
+
.from(accountBans)
|
|
321
|
+
.leftJoin(users, eq(users.accountId, accountBans.accountId))
|
|
322
|
+
.where(and(...conditions))
|
|
323
|
+
.orderBy(desc(accountBans.bannedAt), desc(accountBans.id))
|
|
324
|
+
.limit(input.limit + 1);
|
|
325
|
+
return page(rows, input.limit, asBan, (item) => item.bannedAt);
|
|
326
|
+
}
|
|
327
|
+
async createReport(input) {
|
|
328
|
+
if (![input.targetUserId, input.chatId, input.messageId, input.fileId].some(Boolean))
|
|
329
|
+
throw new OperationsError("invalid", "A report must identify at least one target");
|
|
330
|
+
return this.writeDb(async (tx) => {
|
|
331
|
+
await this.requireActiveUserDb(tx, input.actorUserId);
|
|
332
|
+
await this.requireReportTargetAccess(tx, input);
|
|
333
|
+
const id = createId();
|
|
334
|
+
await tx.insert(moderationReports).values({
|
|
335
|
+
id,
|
|
336
|
+
reportedByUserId: input.actorUserId,
|
|
337
|
+
targetUserId: input.targetUserId,
|
|
338
|
+
chatId: input.chatId,
|
|
339
|
+
messageId: input.messageId,
|
|
340
|
+
fileId: input.fileId,
|
|
341
|
+
reason: input.reason,
|
|
342
|
+
details: input.details,
|
|
343
|
+
});
|
|
344
|
+
const report = await this.reportDb(tx, id);
|
|
345
|
+
await this.appendAudit(tx, {
|
|
346
|
+
actorUserId: input.actorUserId,
|
|
347
|
+
action: "moderation.report_created",
|
|
348
|
+
targetType: "moderation_report",
|
|
349
|
+
targetId: id,
|
|
350
|
+
chatId: input.chatId,
|
|
351
|
+
after: report,
|
|
352
|
+
context: input.context,
|
|
353
|
+
});
|
|
354
|
+
return report;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async listReports(input) {
|
|
358
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
359
|
+
const cursor = decodeCursor(input.before);
|
|
360
|
+
const conditions = [];
|
|
361
|
+
if (input.status)
|
|
362
|
+
conditions.push(eq(moderationReports.status, input.status));
|
|
363
|
+
if (input.assignedToUserId)
|
|
364
|
+
conditions.push(eq(moderationReports.assignedToUserId, input.assignedToUserId));
|
|
365
|
+
if (cursor)
|
|
366
|
+
conditions.push(cursorCondition(moderationReports.createdAt, moderationReports.id, cursor));
|
|
367
|
+
const rows = await this.db
|
|
368
|
+
.select(reportSelection)
|
|
369
|
+
.from(moderationReports)
|
|
370
|
+
.where(and(...conditions))
|
|
371
|
+
.orderBy(desc(moderationReports.createdAt), desc(moderationReports.id))
|
|
372
|
+
.limit(input.limit + 1);
|
|
373
|
+
return page(rows, input.limit, asReport);
|
|
374
|
+
}
|
|
375
|
+
async updateReport(input) {
|
|
376
|
+
if (input.status === undefined &&
|
|
377
|
+
input.assignedToUserId === undefined &&
|
|
378
|
+
input.resolution === undefined)
|
|
379
|
+
throw new OperationsError("invalid", "At least one report field is required");
|
|
380
|
+
return this.writeDb(async (tx) => {
|
|
381
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
382
|
+
const before = await this.reportDb(tx, input.reportId);
|
|
383
|
+
if (input.assignedToUserId)
|
|
384
|
+
await this.requireAdminDb(tx, input.assignedToUserId);
|
|
385
|
+
const status = input.status ?? before.status;
|
|
386
|
+
const assigned = input.assignedToUserId === undefined
|
|
387
|
+
? before.assignedToUserId
|
|
388
|
+
: input.assignedToUserId;
|
|
389
|
+
const resolution = input.resolution === undefined ? before.resolution : input.resolution;
|
|
390
|
+
await tx
|
|
391
|
+
.update(moderationReports)
|
|
392
|
+
.set({
|
|
393
|
+
status,
|
|
394
|
+
assignedToUserId: assigned ?? null,
|
|
395
|
+
resolution: resolution ?? null,
|
|
396
|
+
resolvedAt: status === "resolved" || status === "dismissed"
|
|
397
|
+
? sql `coalesce(${moderationReports.resolvedAt}, CURRENT_TIMESTAMP)`
|
|
398
|
+
: null,
|
|
399
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
400
|
+
})
|
|
401
|
+
.where(eq(moderationReports.id, input.reportId));
|
|
402
|
+
const after = await this.reportDb(tx, input.reportId);
|
|
403
|
+
await this.appendAudit(tx, {
|
|
404
|
+
actorUserId: input.actorUserId,
|
|
405
|
+
action: "moderation.report_updated",
|
|
406
|
+
targetType: "moderation_report",
|
|
407
|
+
targetId: input.reportId,
|
|
408
|
+
chatId: after.chatId,
|
|
409
|
+
before,
|
|
410
|
+
after,
|
|
411
|
+
context: input.context,
|
|
412
|
+
});
|
|
413
|
+
return after;
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
async takeModerationAction(input) {
|
|
417
|
+
const expiresAt = futureTimestamp(input.expiresAt, "expiresAt");
|
|
418
|
+
if (expiresAt && input.action !== "ban" && input.action !== "restrict")
|
|
419
|
+
throw new OperationsError("invalid", `${input.action} does not support expiresAt`);
|
|
420
|
+
return this.writeDb(async (tx) => {
|
|
421
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
422
|
+
if (input.automationRunId) {
|
|
423
|
+
const [existing] = await tx
|
|
424
|
+
.select(moderationActionSelection)
|
|
425
|
+
.from(moderationActions)
|
|
426
|
+
.where(eq(moderationActions.automationRunId, input.automationRunId))
|
|
427
|
+
.limit(1);
|
|
428
|
+
if (existing) {
|
|
429
|
+
const action = asModerationAction(existing);
|
|
430
|
+
if (action.reportId !== input.reportId)
|
|
431
|
+
throw new OperationsError("conflict", "Automation run is already bound to another moderation report");
|
|
432
|
+
return {
|
|
433
|
+
report: await this.reportDb(tx, input.reportId),
|
|
434
|
+
action,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const before = await this.reportDb(tx, input.reportId);
|
|
439
|
+
if ((input.action === "ban" ||
|
|
440
|
+
input.action === "unban" ||
|
|
441
|
+
input.action === "delete_user" ||
|
|
442
|
+
input.action === "warn" ||
|
|
443
|
+
input.action === "restrict") &&
|
|
444
|
+
!before.targetUserId)
|
|
445
|
+
throw new OperationsError("invalid", `${input.action} requires a reported user`);
|
|
446
|
+
if (input.action === "remove_message" && !before.messageId)
|
|
447
|
+
throw new OperationsError("invalid", "remove_message requires a reported message");
|
|
448
|
+
if (input.action === "remove_file" && !before.fileId)
|
|
449
|
+
throw new OperationsError("invalid", "remove_file requires a reported file");
|
|
450
|
+
const actionId = createId();
|
|
451
|
+
let actionChatId = before.chatId;
|
|
452
|
+
let sync;
|
|
453
|
+
if (input.action === "ban")
|
|
454
|
+
sync = await this.applyBanInTransaction(tx, input.actorUserId, before.targetUserId, input.reason, expiresAt);
|
|
455
|
+
else if (input.action === "unban")
|
|
456
|
+
sync = await this.revokeBanInTransaction(tx, input.actorUserId, before.targetUserId, input.reason);
|
|
457
|
+
else if (input.action === "warn" || input.action === "restrict")
|
|
458
|
+
sync = await this.createModerationNotification(tx, {
|
|
459
|
+
actorUserId: input.actorUserId,
|
|
460
|
+
targetUserId: before.targetUserId,
|
|
461
|
+
chatId: before.chatId,
|
|
462
|
+
actionId,
|
|
463
|
+
reportId: input.reportId,
|
|
464
|
+
action: input.action,
|
|
465
|
+
reason: input.reason,
|
|
466
|
+
expiresAt,
|
|
467
|
+
});
|
|
468
|
+
else if (input.action === "remove_message") {
|
|
469
|
+
const removed = await this.removeMessageInTransaction(tx, input.actorUserId, before.messageId, input.reason);
|
|
470
|
+
actionChatId = removed.chatId;
|
|
471
|
+
sync = removed.sync;
|
|
472
|
+
}
|
|
473
|
+
else if (input.action === "remove_file")
|
|
474
|
+
sync = await this.removeFileInTransaction(tx, input.actorUserId, before.fileId, input.reason);
|
|
475
|
+
else if (input.action === "delete_user")
|
|
476
|
+
sync = await this.deleteUserInTransaction(tx, input.actorUserId, before.targetUserId);
|
|
477
|
+
await tx.insert(moderationActions).values({
|
|
478
|
+
id: actionId,
|
|
479
|
+
reportId: input.reportId,
|
|
480
|
+
actorUserId: input.actorUserId,
|
|
481
|
+
targetUserId: before.targetUserId,
|
|
482
|
+
chatId: actionChatId,
|
|
483
|
+
messageId: before.messageId,
|
|
484
|
+
fileId: before.fileId,
|
|
485
|
+
action: input.action,
|
|
486
|
+
reason: input.reason,
|
|
487
|
+
metadataJson: json(input.metadata),
|
|
488
|
+
automationRunId: input.automationRunId,
|
|
489
|
+
expiresAt,
|
|
490
|
+
});
|
|
491
|
+
await tx
|
|
492
|
+
.update(moderationReports)
|
|
493
|
+
.set({
|
|
494
|
+
status: "resolved",
|
|
495
|
+
assignedToUserId: input.actorUserId,
|
|
496
|
+
resolution: input.reason ?? input.action,
|
|
497
|
+
resolvedAt: sql `CURRENT_TIMESTAMP`,
|
|
498
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
499
|
+
})
|
|
500
|
+
.where(eq(moderationReports.id, input.reportId));
|
|
501
|
+
const report = await this.reportDb(tx, input.reportId);
|
|
502
|
+
const action = await this.moderationActionDb(tx, actionId);
|
|
503
|
+
await this.appendAudit(tx, {
|
|
504
|
+
actorUserId: input.actorUserId,
|
|
505
|
+
action: `moderation.${input.action}`,
|
|
506
|
+
targetType: "moderation_report",
|
|
507
|
+
targetId: input.reportId,
|
|
508
|
+
chatId: report.chatId,
|
|
509
|
+
before,
|
|
510
|
+
after: { report, action },
|
|
511
|
+
context: mergeContext(input.context, input.metadata),
|
|
512
|
+
});
|
|
513
|
+
return { report, action, sync };
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async revokeModerationAction(input) {
|
|
517
|
+
return this.writeDb(async (tx) => {
|
|
518
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
519
|
+
const before = await this.moderationActionDb(tx, input.actionId);
|
|
520
|
+
if (before.revokedAt)
|
|
521
|
+
throw new OperationsError("conflict", "Moderation action is already revoked");
|
|
522
|
+
if (before.action !== "restrict")
|
|
523
|
+
throw new OperationsError("conflict", "Only restrictions can be revoked");
|
|
524
|
+
if (!before.targetUserId)
|
|
525
|
+
throw new Error("Restriction is missing its target user");
|
|
526
|
+
await tx
|
|
527
|
+
.update(moderationActions)
|
|
528
|
+
.set({ revokedAt: sql `CURRENT_TIMESTAMP` })
|
|
529
|
+
.where(and(eq(moderationActions.id, input.actionId), isNull(moderationActions.revokedAt)));
|
|
530
|
+
const sync = await this.createModerationNotification(tx, {
|
|
531
|
+
actorUserId: input.actorUserId,
|
|
532
|
+
targetUserId: before.targetUserId,
|
|
533
|
+
chatId: before.chatId,
|
|
534
|
+
actionId: input.actionId,
|
|
535
|
+
reportId: before.reportId,
|
|
536
|
+
action: "restrict_revoked",
|
|
537
|
+
reason: input.reason,
|
|
538
|
+
});
|
|
539
|
+
const action = await this.moderationActionDb(tx, input.actionId);
|
|
540
|
+
await this.appendAudit(tx, {
|
|
541
|
+
actorUserId: input.actorUserId,
|
|
542
|
+
action: "moderation.action_revoked",
|
|
543
|
+
targetType: "moderation_action",
|
|
544
|
+
targetId: input.actionId,
|
|
545
|
+
chatId: before.chatId,
|
|
546
|
+
before,
|
|
547
|
+
after: action,
|
|
548
|
+
context: mergeContext(input.context, { reason: input.reason }),
|
|
549
|
+
});
|
|
550
|
+
return { action, sync };
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
async requestDataExport(input) {
|
|
554
|
+
const expiresAt = futureTimestamp(input.expiresAt, "expiresAt");
|
|
555
|
+
return this.writeDb(async (tx) => {
|
|
556
|
+
const actor = await this.requireActiveUserDb(tx, input.actorUserId);
|
|
557
|
+
let targetId = input.targetId;
|
|
558
|
+
if (input.kind === "user_data") {
|
|
559
|
+
targetId ??= input.actorUserId;
|
|
560
|
+
if (targetId !== input.actorUserId && actor.role !== "admin")
|
|
561
|
+
throw new OperationsError("forbidden", "Only administrators can export another user");
|
|
562
|
+
await this.requireExistingUserDb(tx, targetId);
|
|
563
|
+
}
|
|
564
|
+
else if (input.kind === "chat_history") {
|
|
565
|
+
if (!targetId)
|
|
566
|
+
throw new OperationsError("invalid", "chat_history requires targetId");
|
|
567
|
+
if (!(await this.canAccessChat(tx, input.actorUserId, targetId)))
|
|
568
|
+
throw new OperationsError("not_found", "Chat was not found");
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
if (actor.role !== "admin")
|
|
572
|
+
throw new OperationsError("forbidden", "This export requires an administrator");
|
|
573
|
+
targetId = undefined;
|
|
574
|
+
}
|
|
575
|
+
const id = createId();
|
|
576
|
+
await tx.insert(dataExportJobs).values({
|
|
577
|
+
id,
|
|
578
|
+
requestedByUserId: input.actorUserId,
|
|
579
|
+
kind: input.kind,
|
|
580
|
+
targetId,
|
|
581
|
+
optionsJson: json(input.options),
|
|
582
|
+
expiresAt,
|
|
583
|
+
});
|
|
584
|
+
const job = await this.exportJobDb(tx, id);
|
|
585
|
+
await this.appendAudit(tx, {
|
|
586
|
+
actorUserId: input.actorUserId,
|
|
587
|
+
action: "data_export.requested",
|
|
588
|
+
targetType: "data_export",
|
|
589
|
+
targetId: id,
|
|
590
|
+
chatId: input.kind === "chat_history" ? targetId : undefined,
|
|
591
|
+
after: job,
|
|
592
|
+
context: input.context,
|
|
593
|
+
});
|
|
594
|
+
return job;
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
async getDataExport(actorUserId, jobId) {
|
|
598
|
+
const actor = await this.requireActiveUserDb(this.db, actorUserId);
|
|
599
|
+
const job = await this.exportJobDb(this.db, jobId);
|
|
600
|
+
if (actor.role !== "admin" && job.requestedByUserId !== actorUserId)
|
|
601
|
+
throw new OperationsError("not_found", "Data export was not found");
|
|
602
|
+
return job;
|
|
603
|
+
}
|
|
604
|
+
async listDataExports(input) {
|
|
605
|
+
const actor = await this.requireActiveUserDb(this.db, input.actorUserId);
|
|
606
|
+
if (!input.ownOnly && actor.role !== "admin")
|
|
607
|
+
throw new OperationsError("forbidden", "Administrator access is required");
|
|
608
|
+
const conditions = [];
|
|
609
|
+
if (input.ownOnly)
|
|
610
|
+
conditions.push(eq(dataExportJobs.requestedByUserId, input.actorUserId));
|
|
611
|
+
else if (input.requestedByUserId)
|
|
612
|
+
conditions.push(eq(dataExportJobs.requestedByUserId, input.requestedByUserId));
|
|
613
|
+
if (input.status)
|
|
614
|
+
conditions.push(eq(dataExportJobs.status, input.status));
|
|
615
|
+
const cursor = decodeCursor(input.before);
|
|
616
|
+
if (cursor)
|
|
617
|
+
conditions.push(cursorCondition(dataExportJobs.createdAt, dataExportJobs.id, cursor));
|
|
618
|
+
const rows = await this.db
|
|
619
|
+
.select(exportSelection)
|
|
620
|
+
.from(dataExportJobs)
|
|
621
|
+
.where(and(...conditions))
|
|
622
|
+
.orderBy(desc(dataExportJobs.createdAt), desc(dataExportJobs.id))
|
|
623
|
+
.limit(input.limit + 1);
|
|
624
|
+
return page(rows, input.limit, asExport);
|
|
625
|
+
}
|
|
626
|
+
async cancelDataExport(input) {
|
|
627
|
+
return this.writeDb(async (tx) => {
|
|
628
|
+
const actor = await this.requireActiveUserDb(tx, input.actorUserId);
|
|
629
|
+
const before = await this.exportJobDb(tx, input.jobId);
|
|
630
|
+
if (actor.role !== "admin" && before.requestedByUserId !== input.actorUserId)
|
|
631
|
+
throw new OperationsError("not_found", "Data export was not found");
|
|
632
|
+
if (before.status !== "pending" && before.status !== "running")
|
|
633
|
+
throw new OperationsError("conflict", "Data export can no longer be cancelled");
|
|
634
|
+
await tx
|
|
635
|
+
.update(dataExportJobs)
|
|
636
|
+
.set({ status: "cancelled", completedAt: sql `CURRENT_TIMESTAMP` })
|
|
637
|
+
.where(eq(dataExportJobs.id, input.jobId));
|
|
638
|
+
const after = await this.exportJobDb(tx, input.jobId);
|
|
639
|
+
await this.appendAudit(tx, {
|
|
640
|
+
actorUserId: input.actorUserId,
|
|
641
|
+
action: "data_export.cancelled",
|
|
642
|
+
targetType: "data_export",
|
|
643
|
+
targetId: input.jobId,
|
|
644
|
+
before,
|
|
645
|
+
after,
|
|
646
|
+
context: input.context,
|
|
647
|
+
});
|
|
648
|
+
return after;
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
async updateDataExport(input) {
|
|
652
|
+
const expiresAt = futureTimestamp(input.expiresAt, "expiresAt");
|
|
653
|
+
return this.writeDb(async (tx) => {
|
|
654
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
655
|
+
const before = await this.exportJobDb(tx, input.jobId);
|
|
656
|
+
assertExportTransition(before.status, input.status);
|
|
657
|
+
if (input.status === "complete") {
|
|
658
|
+
if (!input.outputFileId)
|
|
659
|
+
throw new OperationsError("invalid", "A completed export requires outputFileId");
|
|
660
|
+
const [file] = await tx
|
|
661
|
+
.select({ id: files.id })
|
|
662
|
+
.from(files)
|
|
663
|
+
.where(and(eq(files.id, input.outputFileId), isNull(files.deletedAt), eq(files.uploadStatus, "complete")));
|
|
664
|
+
if (!file)
|
|
665
|
+
throw new OperationsError("not_found", "Output file was not found");
|
|
666
|
+
if (!before.requestedByUserId)
|
|
667
|
+
throw new OperationsError("conflict", "Export requester no longer exists; the artifact cannot be granted safely");
|
|
668
|
+
await tx
|
|
669
|
+
.insert(fileAccessGrants)
|
|
670
|
+
.values({
|
|
671
|
+
id: createId(),
|
|
672
|
+
fileId: input.outputFileId,
|
|
673
|
+
principalType: "user",
|
|
674
|
+
principalId: before.requestedByUserId,
|
|
675
|
+
grantedByUserId: input.actorUserId,
|
|
676
|
+
expiresAt: expiresAt ?? before.expiresAt,
|
|
677
|
+
})
|
|
678
|
+
.onConflictDoUpdate({
|
|
679
|
+
target: [
|
|
680
|
+
fileAccessGrants.fileId,
|
|
681
|
+
fileAccessGrants.principalType,
|
|
682
|
+
fileAccessGrants.principalId,
|
|
683
|
+
],
|
|
684
|
+
targetWhere: isNull(fileAccessGrants.sourceMessageId),
|
|
685
|
+
set: {
|
|
686
|
+
expiresAt: sql `CASE WHEN ${fileAccessGrants.expiresAt} IS NULL OR excluded.expires_at IS NULL THEN NULL WHEN ${fileAccessGrants.expiresAt} > excluded.expires_at THEN ${fileAccessGrants.expiresAt} ELSE excluded.expires_at END`,
|
|
687
|
+
},
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
if (input.status === "failed" && !input.lastError)
|
|
691
|
+
throw new OperationsError("invalid", "A failed export requires lastError");
|
|
692
|
+
await tx
|
|
693
|
+
.update(dataExportJobs)
|
|
694
|
+
.set({
|
|
695
|
+
status: input.status,
|
|
696
|
+
outputFileId: input.outputFileId ?? sql `${dataExportJobs.outputFileId}`,
|
|
697
|
+
lastError: input.lastError ?? null,
|
|
698
|
+
expiresAt: expiresAt ?? sql `${dataExportJobs.expiresAt}`,
|
|
699
|
+
startedAt: input.status === "running"
|
|
700
|
+
? sql `coalesce(${dataExportJobs.startedAt}, CURRENT_TIMESTAMP)`
|
|
701
|
+
: sql `${dataExportJobs.startedAt}`,
|
|
702
|
+
completedAt: ["complete", "failed", "cancelled", "expired"].includes(input.status)
|
|
703
|
+
? sql `coalesce(${dataExportJobs.completedAt}, CURRENT_TIMESTAMP)`
|
|
704
|
+
: sql `${dataExportJobs.completedAt}`,
|
|
705
|
+
})
|
|
706
|
+
.where(eq(dataExportJobs.id, input.jobId));
|
|
707
|
+
const after = await this.exportJobDb(tx, input.jobId);
|
|
708
|
+
await this.appendAudit(tx, {
|
|
709
|
+
actorUserId: input.actorUserId,
|
|
710
|
+
action: `data_export.${input.status}`,
|
|
711
|
+
targetType: "data_export",
|
|
712
|
+
targetId: input.jobId,
|
|
713
|
+
before,
|
|
714
|
+
after,
|
|
715
|
+
context: input.context,
|
|
716
|
+
});
|
|
717
|
+
return after;
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
async claimPendingDataExports(limit = 5, leaseMs = 5 * 60_000) {
|
|
721
|
+
const claimedAt = new Date().toISOString();
|
|
722
|
+
const staleBefore = new Date(Date.now() - leaseMs).toISOString();
|
|
723
|
+
const claimable = or(eq(dataExportJobs.status, "pending"), and(eq(dataExportJobs.status, "running"), or(isNull(dataExportJobs.startedAt), lte(dataExportJobs.startedAt, staleBefore))));
|
|
724
|
+
const [candidate] = await this.db
|
|
725
|
+
.select({ id: dataExportJobs.id })
|
|
726
|
+
.from(dataExportJobs)
|
|
727
|
+
.where(claimable)
|
|
728
|
+
.limit(1);
|
|
729
|
+
if (!candidate)
|
|
730
|
+
return [];
|
|
731
|
+
return this.writeDb(async (tx) => {
|
|
732
|
+
const candidates = await tx
|
|
733
|
+
.select({ id: dataExportJobs.id })
|
|
734
|
+
.from(dataExportJobs)
|
|
735
|
+
.where(claimable)
|
|
736
|
+
.orderBy(dataExportJobs.createdAt, dataExportJobs.id)
|
|
737
|
+
.limit(limit);
|
|
738
|
+
const claimed = [];
|
|
739
|
+
for (const candidate of candidates) {
|
|
740
|
+
const [row] = await tx
|
|
741
|
+
.update(dataExportJobs)
|
|
742
|
+
.set({ status: "running", startedAt: claimedAt, lastError: null })
|
|
743
|
+
.where(and(eq(dataExportJobs.id, candidate.id), claimable))
|
|
744
|
+
.returning(exportSelection);
|
|
745
|
+
if (row)
|
|
746
|
+
claimed.push({ ...asExport(row), claimStartedAt: claimedAt });
|
|
747
|
+
}
|
|
748
|
+
return claimed;
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
async buildDataExportArtifact(claim) {
|
|
752
|
+
if (!claim.requestedByUserId)
|
|
753
|
+
throw new Error("Data export requester no longer exists");
|
|
754
|
+
const base = {
|
|
755
|
+
schemaVersion: 1,
|
|
756
|
+
exportId: claim.id,
|
|
757
|
+
kind: claim.kind,
|
|
758
|
+
requestedByUserId: claim.requestedByUserId,
|
|
759
|
+
targetId: claim.targetId,
|
|
760
|
+
createdAt: claim.createdAt,
|
|
761
|
+
generatedAt: new Date().toISOString(),
|
|
762
|
+
options: claim.options,
|
|
763
|
+
};
|
|
764
|
+
if (claim.kind === "user_data") {
|
|
765
|
+
const targetId = claim.targetId ?? claim.requestedByUserId;
|
|
766
|
+
const [profile] = await this.db
|
|
767
|
+
.select({
|
|
768
|
+
id: users.id,
|
|
769
|
+
username: users.username,
|
|
770
|
+
firstName: users.firstName,
|
|
771
|
+
lastName: users.lastName,
|
|
772
|
+
title: users.title,
|
|
773
|
+
email: accounts.email,
|
|
774
|
+
phone: users.phone,
|
|
775
|
+
photoFileId: users.photoFileId,
|
|
776
|
+
createdAt: users.createdAt,
|
|
777
|
+
})
|
|
778
|
+
.from(users)
|
|
779
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
780
|
+
.where(eq(users.id, targetId));
|
|
781
|
+
if (!profile)
|
|
782
|
+
throw new Error("Data export target no longer exists");
|
|
783
|
+
const options = objectValue(claim.options);
|
|
784
|
+
const exportedFiles = options.includeFiles
|
|
785
|
+
? await this.db
|
|
786
|
+
.select({
|
|
787
|
+
id: files.id,
|
|
788
|
+
kind: files.kind,
|
|
789
|
+
originalName: files.originalName,
|
|
790
|
+
contentType: files.contentType,
|
|
791
|
+
size: files.size,
|
|
792
|
+
createdAt: files.createdAt,
|
|
793
|
+
})
|
|
794
|
+
.from(files)
|
|
795
|
+
.where(and(eq(files.uploadedByUserId, targetId), isNull(files.deletedAt)))
|
|
796
|
+
.orderBy(files.createdAt, files.id)
|
|
797
|
+
: [];
|
|
798
|
+
return { ...base, data: { profile, files: exportedFiles } };
|
|
799
|
+
}
|
|
800
|
+
if (claim.kind === "chat_history") {
|
|
801
|
+
if (!claim.targetId ||
|
|
802
|
+
!(await this.canAccessChat(this.db, claim.requestedByUserId, claim.targetId)))
|
|
803
|
+
throw new Error("Data export chat is no longer accessible");
|
|
804
|
+
const [chat] = await this.db
|
|
805
|
+
.select({ id: chats.id, kind: chats.kind, name: chats.name, topic: chats.topic })
|
|
806
|
+
.from(chats)
|
|
807
|
+
.where(eq(chats.id, claim.targetId));
|
|
808
|
+
const exportedMessages = await this.db
|
|
809
|
+
.select({
|
|
810
|
+
id: messages.id,
|
|
811
|
+
sequence: messages.sequence,
|
|
812
|
+
senderUserId: messages.senderUserId,
|
|
813
|
+
senderBotId: messages.senderBotId,
|
|
814
|
+
kind: messages.kind,
|
|
815
|
+
text: messages.text,
|
|
816
|
+
threadRootMessageId: messages.threadRootMessageId,
|
|
817
|
+
createdAt: messages.createdAt,
|
|
818
|
+
editedAt: messages.editedAt,
|
|
819
|
+
deletedAt: messages.deletedAt,
|
|
820
|
+
})
|
|
821
|
+
.from(messages)
|
|
822
|
+
.where(eq(messages.chatId, claim.targetId))
|
|
823
|
+
.orderBy(messages.sequence);
|
|
824
|
+
return { ...base, data: { chat, messages: exportedMessages } };
|
|
825
|
+
}
|
|
826
|
+
await this.requireAdminDb(this.db, claim.requestedByUserId);
|
|
827
|
+
if (claim.kind === "audit_log") {
|
|
828
|
+
const entries = await this.db
|
|
829
|
+
.select(auditSelection)
|
|
830
|
+
.from(auditLogEntries)
|
|
831
|
+
.orderBy(auditLogEntries.createdAt, auditLogEntries.id);
|
|
832
|
+
return { ...base, data: { auditLog: entries.map(asAudit) } };
|
|
833
|
+
}
|
|
834
|
+
const [exportedUsers, exportedChats] = await Promise.all([
|
|
835
|
+
this.db
|
|
836
|
+
.select({
|
|
837
|
+
id: users.id,
|
|
838
|
+
username: users.username,
|
|
839
|
+
role: users.role,
|
|
840
|
+
createdAt: users.createdAt,
|
|
841
|
+
deletedAt: users.deletedAt,
|
|
842
|
+
})
|
|
843
|
+
.from(users)
|
|
844
|
+
.orderBy(users.createdAt, users.id),
|
|
845
|
+
this.db
|
|
846
|
+
.select({
|
|
847
|
+
id: chats.id,
|
|
848
|
+
kind: chats.kind,
|
|
849
|
+
name: chats.name,
|
|
850
|
+
createdAt: chats.createdAt,
|
|
851
|
+
deletedAt: chats.deletedAt,
|
|
852
|
+
})
|
|
853
|
+
.from(chats)
|
|
854
|
+
.orderBy(chats.createdAt, chats.id),
|
|
855
|
+
]);
|
|
856
|
+
return { ...base, data: { users: exportedUsers, chats: exportedChats } };
|
|
857
|
+
}
|
|
858
|
+
async completeClaimedDataExport(claim, outputFileId) {
|
|
859
|
+
const requesterId = claim.requestedByUserId;
|
|
860
|
+
if (!requesterId)
|
|
861
|
+
return false;
|
|
862
|
+
return this.writeDb(async (tx) => {
|
|
863
|
+
const [completed] = await tx
|
|
864
|
+
.update(dataExportJobs)
|
|
865
|
+
.set({
|
|
866
|
+
status: "complete",
|
|
867
|
+
outputFileId,
|
|
868
|
+
lastError: null,
|
|
869
|
+
completedAt: sql `CURRENT_TIMESTAMP`,
|
|
870
|
+
})
|
|
871
|
+
.where(and(eq(dataExportJobs.id, claim.id), eq(dataExportJobs.status, "running"), eq(dataExportJobs.startedAt, claim.claimStartedAt)))
|
|
872
|
+
.returning({ id: dataExportJobs.id });
|
|
873
|
+
if (!completed)
|
|
874
|
+
return false;
|
|
875
|
+
await tx
|
|
876
|
+
.insert(fileAccessGrants)
|
|
877
|
+
.values({
|
|
878
|
+
id: createId(),
|
|
879
|
+
fileId: outputFileId,
|
|
880
|
+
principalType: "user",
|
|
881
|
+
principalId: requesterId,
|
|
882
|
+
grantedByUserId: requesterId,
|
|
883
|
+
expiresAt: claim.expiresAt,
|
|
884
|
+
})
|
|
885
|
+
.onConflictDoUpdate({
|
|
886
|
+
target: [
|
|
887
|
+
fileAccessGrants.fileId,
|
|
888
|
+
fileAccessGrants.principalType,
|
|
889
|
+
fileAccessGrants.principalId,
|
|
890
|
+
],
|
|
891
|
+
targetWhere: isNull(fileAccessGrants.sourceMessageId),
|
|
892
|
+
set: { expiresAt: claim.expiresAt ?? null },
|
|
893
|
+
});
|
|
894
|
+
await this.appendAudit(tx, {
|
|
895
|
+
actorUserId: requesterId,
|
|
896
|
+
action: "data_export.complete",
|
|
897
|
+
targetType: "data_export",
|
|
898
|
+
targetId: claim.id,
|
|
899
|
+
after: { outputFileId },
|
|
900
|
+
});
|
|
901
|
+
return true;
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async failClaimedDataExport(claim, error) {
|
|
905
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
906
|
+
const [failed] = await this.db
|
|
907
|
+
.update(dataExportJobs)
|
|
908
|
+
.set({
|
|
909
|
+
status: "failed",
|
|
910
|
+
lastError: message.slice(0, 2_000),
|
|
911
|
+
completedAt: sql `CURRENT_TIMESTAMP`,
|
|
912
|
+
})
|
|
913
|
+
.where(and(eq(dataExportJobs.id, claim.id), eq(dataExportJobs.status, "running"), eq(dataExportJobs.startedAt, claim.claimStartedAt)))
|
|
914
|
+
.returning({ id: dataExportJobs.id });
|
|
915
|
+
return Boolean(failed);
|
|
916
|
+
}
|
|
917
|
+
async createBackup(input) {
|
|
918
|
+
const retentionUntil = futureTimestamp(input.retentionUntil, "retentionUntil");
|
|
919
|
+
return this.writeDb(async (tx) => {
|
|
920
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
921
|
+
const id = createId();
|
|
922
|
+
try {
|
|
923
|
+
await tx.insert(backupRecords).values({
|
|
924
|
+
id,
|
|
925
|
+
storageProvider: input.storageProvider,
|
|
926
|
+
storageKey: input.storageKey,
|
|
927
|
+
createdByUserId: input.actorUserId,
|
|
928
|
+
metadataJson: json(input.metadata),
|
|
929
|
+
retentionUntil,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
catch (error) {
|
|
933
|
+
if (isUniqueConstraint(error))
|
|
934
|
+
throw new OperationsError("conflict", "Backup storage key is already recorded");
|
|
935
|
+
throw error;
|
|
936
|
+
}
|
|
937
|
+
const backup = await this.backupDb(tx, id);
|
|
938
|
+
await this.appendAudit(tx, {
|
|
939
|
+
actorUserId: input.actorUserId,
|
|
940
|
+
action: "backup.created",
|
|
941
|
+
targetType: "backup",
|
|
942
|
+
targetId: id,
|
|
943
|
+
after: backup,
|
|
944
|
+
context: input.context,
|
|
945
|
+
});
|
|
946
|
+
return backup;
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
async listBackups(input) {
|
|
950
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
951
|
+
const conditions = [];
|
|
952
|
+
if (input.status)
|
|
953
|
+
conditions.push(eq(backupRecords.status, input.status));
|
|
954
|
+
const cursor = decodeCursor(input.before);
|
|
955
|
+
if (cursor)
|
|
956
|
+
conditions.push(cursorCondition(backupRecords.createdAt, backupRecords.id, cursor));
|
|
957
|
+
const rows = await this.db
|
|
958
|
+
.select(backupSelection)
|
|
959
|
+
.from(backupRecords)
|
|
960
|
+
.where(and(...conditions))
|
|
961
|
+
.orderBy(desc(backupRecords.createdAt), desc(backupRecords.id))
|
|
962
|
+
.limit(input.limit + 1);
|
|
963
|
+
return page(rows, input.limit, asBackup);
|
|
964
|
+
}
|
|
965
|
+
async updateBackup(input) {
|
|
966
|
+
const retentionUntil = futureTimestamp(input.retentionUntil, "retentionUntil");
|
|
967
|
+
return this.writeDb(async (tx) => {
|
|
968
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
969
|
+
const before = await this.backupDb(tx, input.backupId);
|
|
970
|
+
assertBackupTransition(before.status, input.status);
|
|
971
|
+
if (input.status === "complete" && (!input.checksumSha256 || input.size === undefined))
|
|
972
|
+
throw new OperationsError("invalid", "A completed backup requires checksumSha256 and size");
|
|
973
|
+
if (input.status === "failed" && !input.lastError)
|
|
974
|
+
throw new OperationsError("invalid", "A failed backup requires lastError");
|
|
975
|
+
await tx
|
|
976
|
+
.update(backupRecords)
|
|
977
|
+
.set({
|
|
978
|
+
status: input.status,
|
|
979
|
+
checksumSha256: input.checksumSha256 ?? sql `${backupRecords.checksumSha256}`,
|
|
980
|
+
size: input.size ?? sql `${backupRecords.size}`,
|
|
981
|
+
lastError: input.lastError ?? null,
|
|
982
|
+
retentionUntil: retentionUntil ?? sql `${backupRecords.retentionUntil}`,
|
|
983
|
+
metadataJson: input.metadata === undefined
|
|
984
|
+
? sql `${backupRecords.metadataJson}`
|
|
985
|
+
: json(input.metadata),
|
|
986
|
+
completedAt: ["complete", "failed", "deleted"].includes(input.status)
|
|
987
|
+
? sql `coalesce(${backupRecords.completedAt}, CURRENT_TIMESTAMP)`
|
|
988
|
+
: sql `${backupRecords.completedAt}`,
|
|
989
|
+
})
|
|
990
|
+
.where(eq(backupRecords.id, input.backupId));
|
|
991
|
+
const after = await this.backupDb(tx, input.backupId);
|
|
992
|
+
await this.appendAudit(tx, {
|
|
993
|
+
actorUserId: input.actorUserId,
|
|
994
|
+
action: `backup.${input.status}`,
|
|
995
|
+
targetType: "backup",
|
|
996
|
+
targetId: input.backupId,
|
|
997
|
+
before,
|
|
998
|
+
after,
|
|
999
|
+
context: input.context,
|
|
1000
|
+
});
|
|
1001
|
+
return after;
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
async startRetentionRun(input) {
|
|
1005
|
+
return this.writeDb(async (tx) => {
|
|
1006
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
1007
|
+
const [active] = await tx
|
|
1008
|
+
.select({ id: retentionRuns.id })
|
|
1009
|
+
.from(retentionRuns)
|
|
1010
|
+
.where(and(eq(retentionRuns.scope, input.scope), eq(retentionRuns.status, "running")))
|
|
1011
|
+
.limit(1);
|
|
1012
|
+
if (active)
|
|
1013
|
+
throw new OperationsError("conflict", "A retention run is already active for this scope");
|
|
1014
|
+
const id = createId();
|
|
1015
|
+
await tx
|
|
1016
|
+
.insert(retentionRuns)
|
|
1017
|
+
.values({ id, scope: input.scope, detailsJson: json(input.details) });
|
|
1018
|
+
const run = await this.retentionRunDb(tx, id);
|
|
1019
|
+
await this.appendAudit(tx, {
|
|
1020
|
+
actorUserId: input.actorUserId,
|
|
1021
|
+
action: "retention.started",
|
|
1022
|
+
targetType: "retention_run",
|
|
1023
|
+
targetId: id,
|
|
1024
|
+
after: run,
|
|
1025
|
+
context: input.context,
|
|
1026
|
+
});
|
|
1027
|
+
return run;
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
async listRetentionRuns(input) {
|
|
1031
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
1032
|
+
const conditions = [];
|
|
1033
|
+
if (input.scope)
|
|
1034
|
+
conditions.push(eq(retentionRuns.scope, input.scope));
|
|
1035
|
+
const cursor = decodeCursor(input.before);
|
|
1036
|
+
if (cursor)
|
|
1037
|
+
conditions.push(cursorCondition(retentionRuns.startedAt, retentionRuns.id, cursor));
|
|
1038
|
+
const rows = await this.db
|
|
1039
|
+
.select(retentionSelection)
|
|
1040
|
+
.from(retentionRuns)
|
|
1041
|
+
.where(and(...conditions))
|
|
1042
|
+
.orderBy(desc(retentionRuns.startedAt), desc(retentionRuns.id))
|
|
1043
|
+
.limit(input.limit + 1);
|
|
1044
|
+
return page(rows, input.limit, asRetention, (item) => item.startedAt);
|
|
1045
|
+
}
|
|
1046
|
+
async finishRetentionRun(input) {
|
|
1047
|
+
return this.writeDb(async (tx) => {
|
|
1048
|
+
await this.requireAdminDb(tx, input.actorUserId);
|
|
1049
|
+
const before = await this.retentionRunDb(tx, input.runId);
|
|
1050
|
+
if (before.status !== "running")
|
|
1051
|
+
throw new OperationsError("conflict", "Retention run is already finished");
|
|
1052
|
+
if (input.status === "failed" && !input.lastError)
|
|
1053
|
+
throw new OperationsError("invalid", "A failed retention run requires lastError");
|
|
1054
|
+
await tx
|
|
1055
|
+
.update(retentionRuns)
|
|
1056
|
+
.set({
|
|
1057
|
+
status: input.status,
|
|
1058
|
+
itemsExamined: input.itemsExamined,
|
|
1059
|
+
itemsDeleted: input.itemsDeleted,
|
|
1060
|
+
detailsJson: input.details === undefined
|
|
1061
|
+
? sql `${retentionRuns.detailsJson}`
|
|
1062
|
+
: json(input.details),
|
|
1063
|
+
lastError: input.lastError ?? null,
|
|
1064
|
+
completedAt: sql `CURRENT_TIMESTAMP`,
|
|
1065
|
+
})
|
|
1066
|
+
.where(eq(retentionRuns.id, input.runId));
|
|
1067
|
+
const after = await this.retentionRunDb(tx, input.runId);
|
|
1068
|
+
await this.appendAudit(tx, {
|
|
1069
|
+
actorUserId: input.actorUserId,
|
|
1070
|
+
action: `retention.${input.status}`,
|
|
1071
|
+
targetType: "retention_run",
|
|
1072
|
+
targetId: input.runId,
|
|
1073
|
+
before,
|
|
1074
|
+
after,
|
|
1075
|
+
context: input.context,
|
|
1076
|
+
});
|
|
1077
|
+
return after;
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
async listUserAccess(input) {
|
|
1081
|
+
await this.requireAdminDb(this.db, input.actorUserId);
|
|
1082
|
+
const cursor = decodeCursor(input.before);
|
|
1083
|
+
const recentEvent = (column) => this.db
|
|
1084
|
+
.select({ value: column })
|
|
1085
|
+
.from(authSessionEvents)
|
|
1086
|
+
.innerJoin(authSessions, eq(authSessions.id, authSessionEvents.sessionId))
|
|
1087
|
+
.where(eq(authSessions.accountId, accounts.id))
|
|
1088
|
+
.orderBy(desc(authSessionEvents.createdAt), desc(authSessionEvents.id))
|
|
1089
|
+
.limit(1);
|
|
1090
|
+
const conditions = [];
|
|
1091
|
+
const accessAt = sql `coalesce(${users.lastAccessAt}, '')`;
|
|
1092
|
+
if (cursor)
|
|
1093
|
+
conditions.push(or(lt(accessAt, cursor.at), and(eq(accessAt, cursor.at), lt(users.id, cursor.id))));
|
|
1094
|
+
const result = await this.db
|
|
1095
|
+
.select({
|
|
1096
|
+
id: users.id,
|
|
1097
|
+
username: users.username,
|
|
1098
|
+
email: accounts.email,
|
|
1099
|
+
role: users.role,
|
|
1100
|
+
last_access_at: users.lastAccessAt,
|
|
1101
|
+
banned_at: accounts.bannedAt,
|
|
1102
|
+
ban_expires_at: accounts.banExpiresAt,
|
|
1103
|
+
deleted_at: accounts.deletedAt,
|
|
1104
|
+
last_session_access_at: sql `max(${authSessions.lastSeenAt})`,
|
|
1105
|
+
active_session_count: sql `sum(case when ${authSessions.revokedAt} is null and ${authSessions.expiresAt} > CURRENT_TIMESTAMP then 1 else 0 end)`,
|
|
1106
|
+
last_client_ip: sql `(${recentEvent(authSessionEvents.ip)})`,
|
|
1107
|
+
last_device: sql `(${recentEvent(authSessionEvents.device)})`,
|
|
1108
|
+
last_app_version: sql `(${recentEvent(authSessionEvents.appVersion)})`,
|
|
1109
|
+
last_user_agent: sql `(${recentEvent(authSessionEvents.userAgent)})`,
|
|
1110
|
+
})
|
|
1111
|
+
.from(users)
|
|
1112
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1113
|
+
.leftJoin(authSessions, eq(authSessions.accountId, accounts.id))
|
|
1114
|
+
.where(and(...conditions))
|
|
1115
|
+
.groupBy(users.id, users.username, accounts.email, users.role, users.lastAccessAt, accounts.bannedAt, accounts.banExpiresAt, accounts.deletedAt)
|
|
1116
|
+
.orderBy(desc(accessAt), desc(users.id))
|
|
1117
|
+
.limit(input.limit + 1);
|
|
1118
|
+
return page(result, input.limit, asAccess, (item) => item.lastAccessAt ?? "", (item) => item.userId);
|
|
1119
|
+
}
|
|
1120
|
+
async requireActiveUserDb(executor, userId) {
|
|
1121
|
+
const [row] = await executor
|
|
1122
|
+
.select({ id: users.id, role: users.role })
|
|
1123
|
+
.from(users)
|
|
1124
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1125
|
+
.where(and(eq(users.id, userId), isNull(users.deletedAt), eq(accounts.active, 1), isNull(accounts.bannedAt), isNull(accounts.deletedAt)));
|
|
1126
|
+
if (!row)
|
|
1127
|
+
throw new OperationsError("not_found", "User was not found");
|
|
1128
|
+
return row;
|
|
1129
|
+
}
|
|
1130
|
+
async requireAdminDb(executor, userId) {
|
|
1131
|
+
const user = await this.requireActiveUserDb(executor, userId);
|
|
1132
|
+
if (user.role !== "admin")
|
|
1133
|
+
throw new OperationsError("forbidden", "Administrator access is required");
|
|
1134
|
+
}
|
|
1135
|
+
async requireExistingUserDb(executor, userId) {
|
|
1136
|
+
const [row] = await executor
|
|
1137
|
+
.select({ id: users.id })
|
|
1138
|
+
.from(users)
|
|
1139
|
+
.where(and(eq(users.id, userId), isNull(users.deletedAt)));
|
|
1140
|
+
if (!row)
|
|
1141
|
+
throw new OperationsError("not_found", "User was not found");
|
|
1142
|
+
}
|
|
1143
|
+
async accountTargetDb(executor, userId) {
|
|
1144
|
+
const [row] = await executor
|
|
1145
|
+
.select({
|
|
1146
|
+
accountId: accounts.id,
|
|
1147
|
+
userId: users.id,
|
|
1148
|
+
username: users.username,
|
|
1149
|
+
bannedAt: accounts.bannedAt,
|
|
1150
|
+
banExpiresAt: accounts.banExpiresAt,
|
|
1151
|
+
banReason: accounts.banReason,
|
|
1152
|
+
bannedByUserId: accounts.bannedByUserId,
|
|
1153
|
+
})
|
|
1154
|
+
.from(users)
|
|
1155
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1156
|
+
.where(and(eq(users.id, userId), isNull(users.deletedAt), isNull(accounts.deletedAt)));
|
|
1157
|
+
if (!row)
|
|
1158
|
+
throw new OperationsError("not_found", "User was not found");
|
|
1159
|
+
return {
|
|
1160
|
+
accountId: row.accountId,
|
|
1161
|
+
userId: row.userId,
|
|
1162
|
+
username: row.username,
|
|
1163
|
+
bannedAt: row.bannedAt ?? undefined,
|
|
1164
|
+
banExpiresAt: row.banExpiresAt ?? undefined,
|
|
1165
|
+
banReason: row.banReason ?? undefined,
|
|
1166
|
+
bannedByUserId: row.bannedByUserId ?? undefined,
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
async applyBanInTransaction(tx, actorUserId, targetUserId, reason, expiresAt) {
|
|
1170
|
+
if (actorUserId === targetUserId)
|
|
1171
|
+
throw new OperationsError("forbidden", "Administrators cannot ban themselves");
|
|
1172
|
+
const target = await this.accountTargetDb(tx, targetUserId);
|
|
1173
|
+
await this.closeElapsedBan(tx, target);
|
|
1174
|
+
if (target.bannedAt &&
|
|
1175
|
+
(!target.banExpiresAt || Date.parse(target.banExpiresAt) > Date.now()))
|
|
1176
|
+
throw new OperationsError("conflict", "User already has an active ban");
|
|
1177
|
+
await tx.insert(accountBans).values({
|
|
1178
|
+
id: createId(),
|
|
1179
|
+
accountId: target.accountId,
|
|
1180
|
+
bannedByUserId: actorUserId,
|
|
1181
|
+
reason,
|
|
1182
|
+
expiresAt,
|
|
1183
|
+
});
|
|
1184
|
+
await tx
|
|
1185
|
+
.update(accounts)
|
|
1186
|
+
.set({
|
|
1187
|
+
bannedAt: sql `CURRENT_TIMESTAMP`,
|
|
1188
|
+
banExpiresAt: expiresAt ?? null,
|
|
1189
|
+
banReason: reason ?? null,
|
|
1190
|
+
bannedByUserId: actorUserId,
|
|
1191
|
+
})
|
|
1192
|
+
.where(eq(accounts.id, target.accountId));
|
|
1193
|
+
await tx
|
|
1194
|
+
.update(authSessions)
|
|
1195
|
+
.set({ revokedAt: sql `CURRENT_TIMESTAMP` })
|
|
1196
|
+
.where(and(eq(authSessions.accountId, target.accountId), isNull(authSessions.revokedAt)));
|
|
1197
|
+
return this.syncUserMutation(tx, actorUserId, target.userId, "user.banned");
|
|
1198
|
+
}
|
|
1199
|
+
async revokeBanInTransaction(tx, actorUserId, targetUserId, reason) {
|
|
1200
|
+
const target = await this.accountTargetDb(tx, targetUserId);
|
|
1201
|
+
if (!target.bannedAt)
|
|
1202
|
+
throw new OperationsError("conflict", "User does not have an active ban");
|
|
1203
|
+
await tx
|
|
1204
|
+
.update(accountBans)
|
|
1205
|
+
.set({
|
|
1206
|
+
revokedAt: sql `CURRENT_TIMESTAMP`,
|
|
1207
|
+
revokedByUserId: actorUserId,
|
|
1208
|
+
revokeReason: reason ?? null,
|
|
1209
|
+
})
|
|
1210
|
+
.where(and(eq(accountBans.accountId, target.accountId), isNull(accountBans.revokedAt)));
|
|
1211
|
+
await tx
|
|
1212
|
+
.update(accounts)
|
|
1213
|
+
.set({ bannedAt: null, banExpiresAt: null, banReason: null, bannedByUserId: null })
|
|
1214
|
+
.where(eq(accounts.id, target.accountId));
|
|
1215
|
+
return this.syncUserMutation(tx, actorUserId, target.userId, "user.unbanned");
|
|
1216
|
+
}
|
|
1217
|
+
async createModerationNotification(tx, input) {
|
|
1218
|
+
await this.requireExistingUserDb(tx, input.targetUserId);
|
|
1219
|
+
const sequence = await this.nextSequence(tx);
|
|
1220
|
+
const notificationId = createId();
|
|
1221
|
+
await tx.insert(notifications).values({
|
|
1222
|
+
id: notificationId,
|
|
1223
|
+
userId: input.targetUserId,
|
|
1224
|
+
kind: "moderation",
|
|
1225
|
+
chatId: input.chatId,
|
|
1226
|
+
actorUserId: input.actorUserId,
|
|
1227
|
+
payloadJson: JSON.stringify({
|
|
1228
|
+
actionId: input.actionId,
|
|
1229
|
+
reportId: input.reportId,
|
|
1230
|
+
action: input.action,
|
|
1231
|
+
reason: input.reason,
|
|
1232
|
+
expiresAt: input.expiresAt,
|
|
1233
|
+
}),
|
|
1234
|
+
syncSequence: sequence,
|
|
1235
|
+
});
|
|
1236
|
+
await this.insertSyncEvent(tx, {
|
|
1237
|
+
sequence,
|
|
1238
|
+
kind: "notification.created",
|
|
1239
|
+
entityId: notificationId,
|
|
1240
|
+
actorUserId: input.actorUserId,
|
|
1241
|
+
targetUserId: input.targetUserId,
|
|
1242
|
+
});
|
|
1243
|
+
return { sequence: String(sequence), chats: [], areas: ["notifications"] };
|
|
1244
|
+
}
|
|
1245
|
+
async removeMessageInTransaction(tx, actorUserId, messageId, reason) {
|
|
1246
|
+
const [message] = await tx
|
|
1247
|
+
.select({
|
|
1248
|
+
chatId: messages.chatId,
|
|
1249
|
+
deletedAt: messages.deletedAt,
|
|
1250
|
+
threadRootMessageId: messages.threadRootMessageId,
|
|
1251
|
+
})
|
|
1252
|
+
.from(messages)
|
|
1253
|
+
.where(eq(messages.id, messageId))
|
|
1254
|
+
.limit(1);
|
|
1255
|
+
if (!message)
|
|
1256
|
+
throw new OperationsError("not_found", "Message was not found");
|
|
1257
|
+
if (message.deletedAt !== null)
|
|
1258
|
+
throw new OperationsError("conflict", "Message is already removed");
|
|
1259
|
+
const chatId = message.chatId;
|
|
1260
|
+
const sequence = await this.nextSequence(tx);
|
|
1261
|
+
const pts = await this.advanceChatMutation(tx, {
|
|
1262
|
+
sequence,
|
|
1263
|
+
chatId,
|
|
1264
|
+
kind: "message.deleted",
|
|
1265
|
+
entityId: messageId,
|
|
1266
|
+
actorUserId,
|
|
1267
|
+
});
|
|
1268
|
+
await tx
|
|
1269
|
+
.update(messages)
|
|
1270
|
+
.set({
|
|
1271
|
+
text: "",
|
|
1272
|
+
contentJson: null,
|
|
1273
|
+
deletedAt: sql `CURRENT_TIMESTAMP`,
|
|
1274
|
+
deletedByUserId: actorUserId,
|
|
1275
|
+
deleteReason: reason ?? "moderation",
|
|
1276
|
+
changePts: pts,
|
|
1277
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1278
|
+
})
|
|
1279
|
+
.where(and(eq(messages.id, messageId), isNull(messages.deletedAt)));
|
|
1280
|
+
await tx
|
|
1281
|
+
.delete(messageSearchDocuments)
|
|
1282
|
+
.where(eq(messageSearchDocuments.messageId, messageId));
|
|
1283
|
+
await tx.delete(messageRevisions).where(eq(messageRevisions.messageId, messageId));
|
|
1284
|
+
await tx.delete(notifications).where(eq(notifications.messageId, messageId));
|
|
1285
|
+
if (message.threadRootMessageId) {
|
|
1286
|
+
const threadRootMessageId = message.threadRootMessageId;
|
|
1287
|
+
await this.recomputeThreadProjection(tx, threadRootMessageId, pts);
|
|
1288
|
+
await tx
|
|
1289
|
+
.update(messages)
|
|
1290
|
+
.set({ changePts: pts })
|
|
1291
|
+
.where(eq(messages.id, threadRootMessageId));
|
|
1292
|
+
}
|
|
1293
|
+
return {
|
|
1294
|
+
chatId,
|
|
1295
|
+
sync: { sequence: String(sequence), chats: [{ chatId, pts: String(pts) }], areas: [] },
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
async removeFileInTransaction(tx, actorUserId, fileId, reason) {
|
|
1299
|
+
const [file] = await tx
|
|
1300
|
+
.select({ deletedAt: files.deletedAt })
|
|
1301
|
+
.from(files)
|
|
1302
|
+
.where(eq(files.id, fileId))
|
|
1303
|
+
.limit(1);
|
|
1304
|
+
if (!file)
|
|
1305
|
+
throw new OperationsError("not_found", "File was not found");
|
|
1306
|
+
if (file.deletedAt !== null)
|
|
1307
|
+
throw new OperationsError("conflict", "File is already removed");
|
|
1308
|
+
const affectedChats = await tx
|
|
1309
|
+
.selectDistinct({ chatId: messages.chatId })
|
|
1310
|
+
.from(messageAttachments)
|
|
1311
|
+
.innerJoin(messages, eq(messages.id, messageAttachments.messageId))
|
|
1312
|
+
.innerJoin(chats, eq(chats.id, messages.chatId))
|
|
1313
|
+
.where(and(eq(messageAttachments.fileId, fileId), isNull(messages.deletedAt), isNull(chats.deletedAt)))
|
|
1314
|
+
.orderBy(messages.chatId);
|
|
1315
|
+
const sequence = await this.nextSequence(tx);
|
|
1316
|
+
const chatPoints = [];
|
|
1317
|
+
for (const row of affectedChats) {
|
|
1318
|
+
const chatId = row.chatId;
|
|
1319
|
+
const pts = await this.advanceChatMutation(tx, {
|
|
1320
|
+
sequence,
|
|
1321
|
+
chatId,
|
|
1322
|
+
kind: "file.removed",
|
|
1323
|
+
entityId: fileId,
|
|
1324
|
+
actorUserId,
|
|
1325
|
+
});
|
|
1326
|
+
chatPoints.push({ chatId, pts: String(pts) });
|
|
1327
|
+
}
|
|
1328
|
+
await tx
|
|
1329
|
+
.update(files)
|
|
1330
|
+
.set({
|
|
1331
|
+
deletedAt: sql `CURRENT_TIMESTAMP`,
|
|
1332
|
+
deletedByUserId: actorUserId,
|
|
1333
|
+
deleteReason: reason ?? "moderation",
|
|
1334
|
+
accessScope: "private",
|
|
1335
|
+
isPublic: 0,
|
|
1336
|
+
orphanedAt: sql `coalesce(${files.orphanedAt}, CURRENT_TIMESTAMP)`,
|
|
1337
|
+
})
|
|
1338
|
+
.where(and(eq(files.id, fileId), isNull(files.deletedAt)));
|
|
1339
|
+
await tx.delete(fileAccessGrants).where(eq(fileAccessGrants.fileId, fileId));
|
|
1340
|
+
await this.insertSyncEvent(tx, {
|
|
1341
|
+
sequence,
|
|
1342
|
+
kind: "file.removed",
|
|
1343
|
+
entityId: fileId,
|
|
1344
|
+
actorUserId,
|
|
1345
|
+
});
|
|
1346
|
+
return { sequence: String(sequence), chats: chatPoints, areas: ["files"] };
|
|
1347
|
+
}
|
|
1348
|
+
async deleteUserInTransaction(tx, actorUserId, targetUserId) {
|
|
1349
|
+
if (actorUserId === targetUserId)
|
|
1350
|
+
throw new OperationsError("forbidden", "Administrators cannot delete themselves");
|
|
1351
|
+
const [target] = await tx
|
|
1352
|
+
.select({ role: users.role, accountId: users.accountId })
|
|
1353
|
+
.from(users)
|
|
1354
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1355
|
+
.where(and(eq(users.id, targetUserId), isNull(users.deletedAt), isNull(accounts.deletedAt)))
|
|
1356
|
+
.limit(1);
|
|
1357
|
+
if (!target)
|
|
1358
|
+
throw new OperationsError("not_found", "User was not found");
|
|
1359
|
+
if (target.role === "admin") {
|
|
1360
|
+
const [otherAdmin] = await tx
|
|
1361
|
+
.select({ id: users.id })
|
|
1362
|
+
.from(users)
|
|
1363
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1364
|
+
.where(and(sql `${users.id} != ${targetUserId}`, eq(users.role, "admin"), isNull(users.deletedAt), eq(accounts.active, 1), isNull(accounts.bannedAt), isNull(accounts.deletedAt)))
|
|
1365
|
+
.limit(1);
|
|
1366
|
+
if (!otherAdmin)
|
|
1367
|
+
throw new OperationsError("forbidden", "The last active administrator cannot be deleted");
|
|
1368
|
+
}
|
|
1369
|
+
const sequence = await this.nextSequence(tx);
|
|
1370
|
+
const memberships = await tx
|
|
1371
|
+
.select({ chatId: chatMembers.chatId, role: chatMembers.role, kind: chats.kind })
|
|
1372
|
+
.from(chatMembers)
|
|
1373
|
+
.innerJoin(chats, eq(chats.id, chatMembers.chatId))
|
|
1374
|
+
.where(and(eq(chatMembers.userId, targetUserId), isNull(chatMembers.leftAt), isNull(chats.deletedAt)));
|
|
1375
|
+
const chatPoints = [];
|
|
1376
|
+
for (const membership of memberships) {
|
|
1377
|
+
const chatId = membership.chatId;
|
|
1378
|
+
let eventKind = "member.deleted";
|
|
1379
|
+
if (membership.kind !== "dm" && membership.role === "owner") {
|
|
1380
|
+
const [successor] = await tx
|
|
1381
|
+
.select({ userId: chatMembers.userId })
|
|
1382
|
+
.from(chatMembers)
|
|
1383
|
+
.innerJoin(users, eq(users.id, chatMembers.userId))
|
|
1384
|
+
.innerJoin(accounts, eq(accounts.id, users.accountId))
|
|
1385
|
+
.where(and(eq(chatMembers.chatId, chatId), sql `${chatMembers.userId} != ${targetUserId}`, isNull(chatMembers.leftAt), isNull(users.deletedAt), eq(accounts.active, 1), isNull(accounts.bannedAt), isNull(accounts.deletedAt)))
|
|
1386
|
+
.orderBy(sql `case ${chatMembers.role} when 'owner' then 0 when 'admin' then 1 else 2 end`, asc(chatMembers.joinedAt), asc(chatMembers.userId))
|
|
1387
|
+
.limit(1);
|
|
1388
|
+
if (successor) {
|
|
1389
|
+
const successorId = successor.userId;
|
|
1390
|
+
await tx
|
|
1391
|
+
.update(chatMembers)
|
|
1392
|
+
.set({
|
|
1393
|
+
role: "owner",
|
|
1394
|
+
syncSequence: sequence,
|
|
1395
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1396
|
+
})
|
|
1397
|
+
.where(and(eq(chatMembers.chatId, chatId), eq(chatMembers.userId, successorId)));
|
|
1398
|
+
await tx
|
|
1399
|
+
.update(chats)
|
|
1400
|
+
.set({ ownerUserId: successorId })
|
|
1401
|
+
.where(eq(chats.id, chatId));
|
|
1402
|
+
eventKind = "member.deletedAndOwnershipTransferred";
|
|
1403
|
+
}
|
|
1404
|
+
else
|
|
1405
|
+
eventKind = "chat.deleted";
|
|
1406
|
+
}
|
|
1407
|
+
const pts = await this.advanceChatMutation(tx, {
|
|
1408
|
+
sequence,
|
|
1409
|
+
chatId,
|
|
1410
|
+
kind: eventKind,
|
|
1411
|
+
entityId: targetUserId,
|
|
1412
|
+
actorUserId,
|
|
1413
|
+
});
|
|
1414
|
+
chatPoints.push({ chatId, pts: String(pts) });
|
|
1415
|
+
if (membership.kind !== "dm")
|
|
1416
|
+
await tx
|
|
1417
|
+
.update(chatMembers)
|
|
1418
|
+
.set({
|
|
1419
|
+
leftAt: sql `CURRENT_TIMESTAMP`,
|
|
1420
|
+
removedByUserId: actorUserId,
|
|
1421
|
+
syncSequence: sequence,
|
|
1422
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1423
|
+
})
|
|
1424
|
+
.where(and(eq(chatMembers.chatId, chatId), eq(chatMembers.userId, targetUserId), isNull(chatMembers.leftAt)));
|
|
1425
|
+
if (eventKind === "chat.deleted")
|
|
1426
|
+
await tx
|
|
1427
|
+
.update(chats)
|
|
1428
|
+
.set({
|
|
1429
|
+
deletedAt: sql `CURRENT_TIMESTAMP`,
|
|
1430
|
+
deletedByUserId: actorUserId,
|
|
1431
|
+
deleteReason: "last member deleted",
|
|
1432
|
+
ownerUserId: null,
|
|
1433
|
+
})
|
|
1434
|
+
.where(eq(chats.id, chatId));
|
|
1435
|
+
}
|
|
1436
|
+
if (!target.accountId)
|
|
1437
|
+
throw new Error("User account is missing");
|
|
1438
|
+
const accountId = target.accountId;
|
|
1439
|
+
await tx
|
|
1440
|
+
.update(authSessions)
|
|
1441
|
+
.set({ revokedAt: sql `CURRENT_TIMESTAMP` })
|
|
1442
|
+
.where(and(eq(authSessions.accountId, accountId), isNull(authSessions.revokedAt)));
|
|
1443
|
+
await tx
|
|
1444
|
+
.update(accounts)
|
|
1445
|
+
.set({
|
|
1446
|
+
deletedAt: sql `CURRENT_TIMESTAMP`,
|
|
1447
|
+
active: 0,
|
|
1448
|
+
passwordHash: null,
|
|
1449
|
+
bannedAt: null,
|
|
1450
|
+
banExpiresAt: null,
|
|
1451
|
+
banReason: null,
|
|
1452
|
+
bannedByUserId: null,
|
|
1453
|
+
email: sql `'deleted+' || ${accounts.id} || '@invalid.local'`,
|
|
1454
|
+
})
|
|
1455
|
+
.where(and(eq(accounts.id, accountId), isNull(accounts.deletedAt)));
|
|
1456
|
+
await tx
|
|
1457
|
+
.update(users)
|
|
1458
|
+
.set({
|
|
1459
|
+
deletedAt: sql `CURRENT_TIMESTAMP`,
|
|
1460
|
+
syncSequence: sequence,
|
|
1461
|
+
firstName: "Deleted",
|
|
1462
|
+
lastName: null,
|
|
1463
|
+
title: null,
|
|
1464
|
+
username: sql `'deleted_' || ${users.id}`,
|
|
1465
|
+
email: null,
|
|
1466
|
+
phone: null,
|
|
1467
|
+
photoFileId: null,
|
|
1468
|
+
})
|
|
1469
|
+
.where(and(eq(users.id, targetUserId), isNull(users.deletedAt)));
|
|
1470
|
+
await this.insertSyncEvent(tx, {
|
|
1471
|
+
sequence,
|
|
1472
|
+
kind: "user.deleted",
|
|
1473
|
+
entityId: targetUserId,
|
|
1474
|
+
actorUserId,
|
|
1475
|
+
});
|
|
1476
|
+
return { sequence: String(sequence), chats: chatPoints, areas: ["users"] };
|
|
1477
|
+
}
|
|
1478
|
+
async recomputeThreadProjection(tx, threadRootMessageId, pts) {
|
|
1479
|
+
await tx
|
|
1480
|
+
.delete(threadParticipants)
|
|
1481
|
+
.where(eq(threadParticipants.threadRootMessageId, threadRootMessageId));
|
|
1482
|
+
const participantRows = await tx
|
|
1483
|
+
.select({
|
|
1484
|
+
userId: messages.senderUserId,
|
|
1485
|
+
replyCount: sql `count(*)`,
|
|
1486
|
+
firstParticipatedAt: sql `min(${messages.createdAt})`,
|
|
1487
|
+
lastParticipatedAt: sql `max(${messages.createdAt})`,
|
|
1488
|
+
})
|
|
1489
|
+
.from(messages)
|
|
1490
|
+
.where(and(eq(messages.threadRootMessageId, threadRootMessageId), sql `${messages.senderUserId} IS NOT NULL`, isNull(messages.deletedAt), or(isNull(messages.expiresAt), sql `datetime(${messages.expiresAt}) > CURRENT_TIMESTAMP`)))
|
|
1491
|
+
.groupBy(messages.senderUserId);
|
|
1492
|
+
if (participantRows.length)
|
|
1493
|
+
await tx.insert(threadParticipants).values(participantRows.map((row) => ({
|
|
1494
|
+
threadRootMessageId,
|
|
1495
|
+
userId: row.userId,
|
|
1496
|
+
replyCount: row.replyCount,
|
|
1497
|
+
firstParticipatedAt: row.firstParticipatedAt,
|
|
1498
|
+
lastParticipatedAt: row.lastParticipatedAt,
|
|
1499
|
+
})));
|
|
1500
|
+
const activeReplies = and(eq(messages.threadRootMessageId, threadRootMessageId), isNull(messages.deletedAt), or(isNull(messages.expiresAt), sql `datetime(${messages.expiresAt}) > CURRENT_TIMESTAMP`));
|
|
1501
|
+
const [lastReply] = await tx
|
|
1502
|
+
.select({ id: messages.id, sequence: messages.sequence })
|
|
1503
|
+
.from(messages)
|
|
1504
|
+
.where(activeReplies)
|
|
1505
|
+
.orderBy(desc(messages.sequence))
|
|
1506
|
+
.limit(1);
|
|
1507
|
+
const [replyCount] = await tx
|
|
1508
|
+
.select({ count: sql `count(*)` })
|
|
1509
|
+
.from(messages)
|
|
1510
|
+
.where(activeReplies);
|
|
1511
|
+
const [participantCount] = await tx
|
|
1512
|
+
.select({ count: sql `count(*)` })
|
|
1513
|
+
.from(threadParticipants)
|
|
1514
|
+
.where(eq(threadParticipants.threadRootMessageId, threadRootMessageId));
|
|
1515
|
+
await tx
|
|
1516
|
+
.update(threads)
|
|
1517
|
+
.set({
|
|
1518
|
+
replyCount: replyCount?.count ?? 0,
|
|
1519
|
+
participantCount: participantCount?.count ?? 0,
|
|
1520
|
+
lastReplyMessageId: lastReply?.id ?? null,
|
|
1521
|
+
lastReplySequence: lastReply?.sequence ?? 0,
|
|
1522
|
+
lastPts: pts,
|
|
1523
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1524
|
+
})
|
|
1525
|
+
.where(eq(threads.rootMessageId, threadRootMessageId));
|
|
1526
|
+
await tx
|
|
1527
|
+
.update(threadUserStates)
|
|
1528
|
+
.set({
|
|
1529
|
+
unreadCount: sql `(select count(*) from messages m where m.thread_root_message_id = ${threadRootMessageId} and m.deleted_at is null and (m.expires_at is null or datetime(m.expires_at) > CURRENT_TIMESTAMP) and m.sequence > ${threadUserStates.lastReadSequence} and (m.sender_user_id is null or m.sender_user_id != ${threadUserStates.userId}))`,
|
|
1530
|
+
mentionCount: sql `(select count(*) from message_mentions mm join messages m on m.id = mm.message_id where m.thread_root_message_id = ${threadRootMessageId} and m.deleted_at is null and m.sequence > ${threadUserStates.lastReadSequence} and mm.mentioned_user_id = ${threadUserStates.userId})`,
|
|
1531
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1532
|
+
})
|
|
1533
|
+
.where(eq(threadUserStates.threadRootMessageId, threadRootMessageId));
|
|
1534
|
+
}
|
|
1535
|
+
async nextSequence(tx) {
|
|
1536
|
+
const [state] = await tx
|
|
1537
|
+
.update(serverSyncState)
|
|
1538
|
+
.set({
|
|
1539
|
+
sequence: sql `${serverSyncState.sequence} + 1`,
|
|
1540
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1541
|
+
})
|
|
1542
|
+
.where(eq(serverSyncState.id, 1))
|
|
1543
|
+
.returning({ sequence: serverSyncState.sequence });
|
|
1544
|
+
if (!state)
|
|
1545
|
+
throw new Error("Sync state has not been initialized");
|
|
1546
|
+
return state.sequence;
|
|
1547
|
+
}
|
|
1548
|
+
async advanceChatMutation(tx, input) {
|
|
1549
|
+
const [chat] = await tx
|
|
1550
|
+
.update(chats)
|
|
1551
|
+
.set({
|
|
1552
|
+
pts: sql `${chats.pts} + 1`,
|
|
1553
|
+
lastChangeSequence: input.sequence,
|
|
1554
|
+
updatedAt: sql `CURRENT_TIMESTAMP`,
|
|
1555
|
+
})
|
|
1556
|
+
.where(and(eq(chats.id, input.chatId), isNull(chats.deletedAt)))
|
|
1557
|
+
.returning({ pts: chats.pts });
|
|
1558
|
+
if (!chat)
|
|
1559
|
+
throw new OperationsError("not_found", "Chat was not found");
|
|
1560
|
+
const pts = chat.pts;
|
|
1561
|
+
await tx.insert(chatUpdates).values({
|
|
1562
|
+
chatId: input.chatId,
|
|
1563
|
+
pts,
|
|
1564
|
+
ptsCount: 1,
|
|
1565
|
+
kind: input.kind,
|
|
1566
|
+
entityId: input.entityId,
|
|
1567
|
+
});
|
|
1568
|
+
await this.insertSyncEvent(tx, { ...input, chatPts: pts });
|
|
1569
|
+
return pts;
|
|
1570
|
+
}
|
|
1571
|
+
async insertSyncEvent(tx, input) {
|
|
1572
|
+
await tx.insert(syncEvents).values({
|
|
1573
|
+
sequence: input.sequence,
|
|
1574
|
+
kind: input.kind,
|
|
1575
|
+
chatId: input.chatId,
|
|
1576
|
+
chatPts: input.chatPts,
|
|
1577
|
+
entityId: input.entityId,
|
|
1578
|
+
actorUserId: input.actorUserId,
|
|
1579
|
+
targetUserId: input.targetUserId,
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
async syncUserMutation(tx, actorUserId, targetUserId, kind) {
|
|
1583
|
+
const sequence = await this.nextSequence(tx);
|
|
1584
|
+
await tx.update(users).set({ syncSequence: sequence }).where(eq(users.id, targetUserId));
|
|
1585
|
+
await tx
|
|
1586
|
+
.insert(syncEvents)
|
|
1587
|
+
.values({ sequence, kind, entityId: targetUserId, actorUserId, targetUserId });
|
|
1588
|
+
return { sequence: String(sequence), chats: [], areas: ["users"] };
|
|
1589
|
+
}
|
|
1590
|
+
async closeElapsedBan(tx, target) {
|
|
1591
|
+
if (!target.bannedAt ||
|
|
1592
|
+
!target.banExpiresAt ||
|
|
1593
|
+
Date.parse(target.banExpiresAt) > Date.now())
|
|
1594
|
+
return;
|
|
1595
|
+
const now = new Date().toISOString();
|
|
1596
|
+
await tx
|
|
1597
|
+
.update(accountBans)
|
|
1598
|
+
.set({
|
|
1599
|
+
revokedAt: sql `coalesce(${accountBans.revokedAt}, ${now})`,
|
|
1600
|
+
revokeReason: sql `coalesce(${accountBans.revokeReason}, 'expired')`,
|
|
1601
|
+
})
|
|
1602
|
+
.where(and(eq(accountBans.accountId, target.accountId), isNull(accountBans.revokedAt), lte(accountBans.expiresAt, now)));
|
|
1603
|
+
await tx
|
|
1604
|
+
.update(accounts)
|
|
1605
|
+
.set({ bannedAt: null, banExpiresAt: null, banReason: null, bannedByUserId: null })
|
|
1606
|
+
.where(and(eq(accounts.id, target.accountId), sql `${accounts.banExpiresAt} IS NOT NULL`, lte(accounts.banExpiresAt, now)));
|
|
1607
|
+
}
|
|
1608
|
+
async requireReportTargetAccess(tx, input) {
|
|
1609
|
+
if (input.targetUserId)
|
|
1610
|
+
await this.requireExistingUserDb(tx, input.targetUserId);
|
|
1611
|
+
if (input.chatId && !(await this.canAccessChat(tx, input.actorUserId, input.chatId)))
|
|
1612
|
+
throw new OperationsError("not_found", "Chat was not found");
|
|
1613
|
+
if (input.messageId) {
|
|
1614
|
+
const [message] = await tx
|
|
1615
|
+
.select({ chatId: messages.chatId })
|
|
1616
|
+
.from(messages)
|
|
1617
|
+
.where(eq(messages.id, input.messageId))
|
|
1618
|
+
.limit(1);
|
|
1619
|
+
if (!message || !(await this.canAccessChat(tx, input.actorUserId, message.chatId)))
|
|
1620
|
+
throw new OperationsError("not_found", "Message was not found");
|
|
1621
|
+
if (input.chatId && input.chatId !== message.chatId)
|
|
1622
|
+
throw new OperationsError("invalid", "messageId does not belong to chatId");
|
|
1623
|
+
}
|
|
1624
|
+
if (input.fileId) {
|
|
1625
|
+
const grants = tx
|
|
1626
|
+
.select({ found: sql `1` })
|
|
1627
|
+
.from(fileAccessGrants)
|
|
1628
|
+
.where(and(eq(fileAccessGrants.fileId, files.id), or(and(eq(fileAccessGrants.principalType, "user"), eq(fileAccessGrants.principalId, input.actorUserId)), eq(fileAccessGrants.principalType, "server"), and(eq(fileAccessGrants.principalType, "chat"), sql `exists (select 1 from chats c left join chat_members cm on cm.chat_id = c.id and cm.user_id = ${input.actorUserId} and cm.left_at is null where c.id = ${fileAccessGrants.principalId} and c.deleted_at is null and (c.visibility = 'public' or cm.user_id is not null))`)), or(isNull(fileAccessGrants.expiresAt), gt(fileAccessGrants.expiresAt, sql `CURRENT_TIMESTAMP`))));
|
|
1629
|
+
const attachments = tx
|
|
1630
|
+
.select({ found: sql `1` })
|
|
1631
|
+
.from(messageAttachments)
|
|
1632
|
+
.innerJoin(messages, eq(messages.id, messageAttachments.messageId))
|
|
1633
|
+
.innerJoin(chats, eq(chats.id, messages.chatId))
|
|
1634
|
+
.leftJoin(chatMembers, and(eq(chatMembers.chatId, chats.id), eq(chatMembers.userId, input.actorUserId), isNull(chatMembers.leftAt)))
|
|
1635
|
+
.where(and(eq(messageAttachments.fileId, files.id), isNull(messages.deletedAt), isNull(chats.deletedAt), or(eq(chats.visibility, "public"), sql `${chatMembers.userId} IS NOT NULL`)));
|
|
1636
|
+
const [accessible] = await tx
|
|
1637
|
+
.select({ id: files.id })
|
|
1638
|
+
.from(files)
|
|
1639
|
+
.where(and(eq(files.id, input.fileId), isNull(files.deletedAt), or(eq(files.isPublic, 1), eq(files.uploadedByUserId, input.actorUserId), sql `exists ${grants}`, sql `exists ${attachments}`)))
|
|
1640
|
+
.limit(1);
|
|
1641
|
+
if (!accessible)
|
|
1642
|
+
throw new OperationsError("not_found", "File was not found");
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
async canAccessChat(executor, userId, chatId) {
|
|
1646
|
+
const [row] = await executor
|
|
1647
|
+
.select({ id: chats.id })
|
|
1648
|
+
.from(chats)
|
|
1649
|
+
.leftJoin(chatMembers, and(eq(chatMembers.chatId, chats.id), eq(chatMembers.userId, userId), isNull(chatMembers.leftAt)))
|
|
1650
|
+
.where(and(eq(chats.id, chatId), isNull(chats.deletedAt), or(eq(chats.visibility, "public"), sql `${chatMembers.userId} IS NOT NULL`)))
|
|
1651
|
+
.limit(1);
|
|
1652
|
+
return Boolean(row);
|
|
1653
|
+
}
|
|
1654
|
+
async banDb(executor, id) {
|
|
1655
|
+
const [row] = await executor
|
|
1656
|
+
.select(banSelection)
|
|
1657
|
+
.from(accountBans)
|
|
1658
|
+
.leftJoin(users, eq(users.accountId, accountBans.accountId))
|
|
1659
|
+
.where(eq(accountBans.id, id))
|
|
1660
|
+
.limit(1);
|
|
1661
|
+
if (!row)
|
|
1662
|
+
throw new OperationsError("not_found", "Ban was not found");
|
|
1663
|
+
return asBan(row);
|
|
1664
|
+
}
|
|
1665
|
+
async reportDb(executor, id) {
|
|
1666
|
+
const [row] = await executor
|
|
1667
|
+
.select(reportSelection)
|
|
1668
|
+
.from(moderationReports)
|
|
1669
|
+
.where(eq(moderationReports.id, id))
|
|
1670
|
+
.limit(1);
|
|
1671
|
+
if (!row)
|
|
1672
|
+
throw new OperationsError("not_found", "Moderation report was not found");
|
|
1673
|
+
return asReport(row);
|
|
1674
|
+
}
|
|
1675
|
+
async moderationActionDb(executor, id) {
|
|
1676
|
+
const [row] = await executor
|
|
1677
|
+
.select(moderationActionSelection)
|
|
1678
|
+
.from(moderationActions)
|
|
1679
|
+
.where(eq(moderationActions.id, id))
|
|
1680
|
+
.limit(1);
|
|
1681
|
+
if (!row)
|
|
1682
|
+
throw new OperationsError("not_found", "Moderation action was not found");
|
|
1683
|
+
return asModerationAction(row);
|
|
1684
|
+
}
|
|
1685
|
+
async exportJobDb(executor, id) {
|
|
1686
|
+
const [row] = await executor
|
|
1687
|
+
.select(exportSelection)
|
|
1688
|
+
.from(dataExportJobs)
|
|
1689
|
+
.where(eq(dataExportJobs.id, id))
|
|
1690
|
+
.limit(1);
|
|
1691
|
+
if (!row)
|
|
1692
|
+
throw new OperationsError("not_found", "Data export was not found");
|
|
1693
|
+
return asExport(row);
|
|
1694
|
+
}
|
|
1695
|
+
async backupDb(executor, id) {
|
|
1696
|
+
const [row] = await executor
|
|
1697
|
+
.select(backupSelection)
|
|
1698
|
+
.from(backupRecords)
|
|
1699
|
+
.where(eq(backupRecords.id, id))
|
|
1700
|
+
.limit(1);
|
|
1701
|
+
if (!row)
|
|
1702
|
+
throw new OperationsError("not_found", "Backup was not found");
|
|
1703
|
+
return asBackup(row);
|
|
1704
|
+
}
|
|
1705
|
+
async retentionRunDb(executor, id) {
|
|
1706
|
+
const [row] = await executor
|
|
1707
|
+
.select(retentionSelection)
|
|
1708
|
+
.from(retentionRuns)
|
|
1709
|
+
.where(eq(retentionRuns.id, id))
|
|
1710
|
+
.limit(1);
|
|
1711
|
+
if (!row)
|
|
1712
|
+
throw new OperationsError("not_found", "Retention run was not found");
|
|
1713
|
+
return asRetention(row);
|
|
1714
|
+
}
|
|
1715
|
+
async appendAudit(tx, input) {
|
|
1716
|
+
const request = input.context?.request;
|
|
1717
|
+
const metadata = {
|
|
1718
|
+
...input.context?.metadata,
|
|
1719
|
+
...(request?.forwardedFor ? { forwardedFor: request.forwardedFor } : {}),
|
|
1720
|
+
...(request?.location ? { location: request.location } : {}),
|
|
1721
|
+
};
|
|
1722
|
+
await tx.insert(auditLogEntries).values({
|
|
1723
|
+
id: createId(),
|
|
1724
|
+
actorUserId: input.actorUserId,
|
|
1725
|
+
action: input.action,
|
|
1726
|
+
targetType: input.targetType,
|
|
1727
|
+
targetId: input.targetId,
|
|
1728
|
+
chatId: input.chatId,
|
|
1729
|
+
beforeJson: json(input.before),
|
|
1730
|
+
afterJson: json(input.after),
|
|
1731
|
+
metadataJson: Object.keys(metadata).length ? JSON.stringify(metadata) : null,
|
|
1732
|
+
clientIp: request?.ip,
|
|
1733
|
+
device: request?.device,
|
|
1734
|
+
appVersion: request?.appVersion,
|
|
1735
|
+
userAgent: request?.userAgent,
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
writeDb(operation) {
|
|
1739
|
+
return retrySqliteBusy(() => this.db.transaction(operation));
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
function cursorCondition(timestampColumn, idColumn, cursor) {
|
|
1743
|
+
return or(lt(timestampColumn, cursor.at), and(eq(timestampColumn, cursor.at), lt(idColumn, cursor.id)));
|
|
1744
|
+
}
|
|
1745
|
+
function decodeCursor(value) {
|
|
1746
|
+
if (!value)
|
|
1747
|
+
return undefined;
|
|
1748
|
+
if (value.length > 1_024)
|
|
1749
|
+
throw new OperationsError("invalid", "Cursor is invalid");
|
|
1750
|
+
try {
|
|
1751
|
+
const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
|
|
1752
|
+
if (typeof parsed.at !== "string" ||
|
|
1753
|
+
typeof parsed.id !== "string" ||
|
|
1754
|
+
parsed.at.length > 64 ||
|
|
1755
|
+
parsed.id.length > 128)
|
|
1756
|
+
throw new Error("bad cursor");
|
|
1757
|
+
return { at: parsed.at, id: parsed.id };
|
|
1758
|
+
}
|
|
1759
|
+
catch {
|
|
1760
|
+
throw new OperationsError("invalid", "Cursor is invalid");
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function encodeCursor(at, id) {
|
|
1764
|
+
return Buffer.from(JSON.stringify({ at, id }), "utf8").toString("base64url");
|
|
1765
|
+
}
|
|
1766
|
+
function page(rows, limit, map, timestamp = (item) => item.createdAt, id = (item) => item.id) {
|
|
1767
|
+
const hasMore = rows.length > limit;
|
|
1768
|
+
const items = rows.slice(0, limit).map(map);
|
|
1769
|
+
const last = items.at(-1);
|
|
1770
|
+
return {
|
|
1771
|
+
items,
|
|
1772
|
+
nextCursor: hasMore && last ? encodeCursor(timestamp(last), id(last)) : undefined,
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function asAudit(row) {
|
|
1776
|
+
return {
|
|
1777
|
+
id: text(row.id),
|
|
1778
|
+
actorUserId: optionalText(row.actor_user_id),
|
|
1779
|
+
actorIntegrationId: optionalText(row.actor_integration_id),
|
|
1780
|
+
action: text(row.action),
|
|
1781
|
+
targetType: text(row.target_type),
|
|
1782
|
+
targetId: optionalText(row.target_id),
|
|
1783
|
+
chatId: optionalText(row.chat_id),
|
|
1784
|
+
before: parseJson(row.before_json),
|
|
1785
|
+
after: parseJson(row.after_json),
|
|
1786
|
+
metadata: parseJson(row.metadata_json),
|
|
1787
|
+
clientIp: optionalText(row.client_ip),
|
|
1788
|
+
device: optionalText(row.device),
|
|
1789
|
+
appVersion: optionalText(row.app_version),
|
|
1790
|
+
userAgent: optionalText(row.user_agent),
|
|
1791
|
+
createdAt: text(row.created_at),
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
function asBan(row) {
|
|
1795
|
+
const revokedAt = optionalText(row.revoked_at);
|
|
1796
|
+
const expiresAt = optionalText(row.expires_at);
|
|
1797
|
+
return {
|
|
1798
|
+
id: text(row.id),
|
|
1799
|
+
accountId: text(row.account_id),
|
|
1800
|
+
userId: optionalText(row.user_id),
|
|
1801
|
+
username: optionalText(row.username),
|
|
1802
|
+
bannedByUserId: optionalText(row.banned_by_user_id),
|
|
1803
|
+
reason: optionalText(row.reason),
|
|
1804
|
+
bannedAt: text(row.banned_at),
|
|
1805
|
+
expiresAt,
|
|
1806
|
+
revokedAt,
|
|
1807
|
+
revokedByUserId: optionalText(row.revoked_by_user_id),
|
|
1808
|
+
revokeReason: optionalText(row.revoke_reason),
|
|
1809
|
+
status: revokedAt
|
|
1810
|
+
? "revoked"
|
|
1811
|
+
: expiresAt && Date.parse(expiresAt) <= Date.now()
|
|
1812
|
+
? "expired"
|
|
1813
|
+
: "active",
|
|
1814
|
+
};
|
|
1815
|
+
}
|
|
1816
|
+
function asReport(row) {
|
|
1817
|
+
return {
|
|
1818
|
+
id: text(row.id),
|
|
1819
|
+
reportedByUserId: optionalText(row.reported_by_user_id),
|
|
1820
|
+
targetUserId: optionalText(row.target_user_id),
|
|
1821
|
+
chatId: optionalText(row.chat_id),
|
|
1822
|
+
messageId: optionalText(row.message_id),
|
|
1823
|
+
fileId: optionalText(row.file_id),
|
|
1824
|
+
reason: text(row.reason),
|
|
1825
|
+
details: optionalText(row.details),
|
|
1826
|
+
status: text(row.status),
|
|
1827
|
+
assignedToUserId: optionalText(row.assigned_to_user_id),
|
|
1828
|
+
resolution: optionalText(row.resolution),
|
|
1829
|
+
createdAt: text(row.created_at),
|
|
1830
|
+
updatedAt: text(row.updated_at),
|
|
1831
|
+
resolvedAt: optionalText(row.resolved_at),
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
function asModerationAction(row) {
|
|
1835
|
+
return {
|
|
1836
|
+
id: text(row.id),
|
|
1837
|
+
reportId: optionalText(row.report_id),
|
|
1838
|
+
actorUserId: optionalText(row.actor_user_id),
|
|
1839
|
+
targetUserId: optionalText(row.target_user_id),
|
|
1840
|
+
chatId: optionalText(row.chat_id),
|
|
1841
|
+
messageId: optionalText(row.message_id),
|
|
1842
|
+
fileId: optionalText(row.file_id),
|
|
1843
|
+
action: text(row.action),
|
|
1844
|
+
reason: optionalText(row.reason),
|
|
1845
|
+
metadata: parseJson(row.metadata_json),
|
|
1846
|
+
expiresAt: optionalText(row.expires_at),
|
|
1847
|
+
revokedAt: optionalText(row.revoked_at),
|
|
1848
|
+
createdAt: text(row.created_at),
|
|
1849
|
+
};
|
|
1850
|
+
}
|
|
1851
|
+
function asExport(row) {
|
|
1852
|
+
return {
|
|
1853
|
+
id: text(row.id),
|
|
1854
|
+
requestedByUserId: optionalText(row.requested_by_user_id),
|
|
1855
|
+
kind: text(row.kind),
|
|
1856
|
+
targetId: optionalText(row.target_id),
|
|
1857
|
+
status: text(row.status),
|
|
1858
|
+
outputFileId: optionalText(row.output_file_id),
|
|
1859
|
+
options: parseJson(row.options_json),
|
|
1860
|
+
lastError: optionalText(row.last_error),
|
|
1861
|
+
expiresAt: optionalText(row.expires_at),
|
|
1862
|
+
createdAt: text(row.created_at),
|
|
1863
|
+
startedAt: optionalText(row.started_at),
|
|
1864
|
+
completedAt: optionalText(row.completed_at),
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
function asBackup(row) {
|
|
1868
|
+
return {
|
|
1869
|
+
id: text(row.id),
|
|
1870
|
+
storageProvider: text(row.storage_provider),
|
|
1871
|
+
storageKey: text(row.storage_key),
|
|
1872
|
+
checksumSha256: optionalText(row.checksum_sha256),
|
|
1873
|
+
size: optionalNumber(row.size),
|
|
1874
|
+
status: text(row.status),
|
|
1875
|
+
createdByUserId: optionalText(row.created_by_user_id),
|
|
1876
|
+
metadata: parseJson(row.metadata_json),
|
|
1877
|
+
lastError: optionalText(row.last_error),
|
|
1878
|
+
createdAt: text(row.created_at),
|
|
1879
|
+
completedAt: optionalText(row.completed_at),
|
|
1880
|
+
retentionUntil: optionalText(row.retention_until),
|
|
1881
|
+
};
|
|
1882
|
+
}
|
|
1883
|
+
function asRetention(row) {
|
|
1884
|
+
return {
|
|
1885
|
+
id: text(row.id),
|
|
1886
|
+
scope: text(row.scope),
|
|
1887
|
+
status: text(row.status),
|
|
1888
|
+
itemsExamined: number(row.items_examined),
|
|
1889
|
+
itemsDeleted: number(row.items_deleted),
|
|
1890
|
+
details: parseJson(row.details_json),
|
|
1891
|
+
lastError: optionalText(row.last_error),
|
|
1892
|
+
startedAt: text(row.started_at),
|
|
1893
|
+
completedAt: optionalText(row.completed_at),
|
|
1894
|
+
};
|
|
1895
|
+
}
|
|
1896
|
+
function asAccess(row) {
|
|
1897
|
+
return {
|
|
1898
|
+
userId: text(row.id),
|
|
1899
|
+
username: text(row.username),
|
|
1900
|
+
email: text(row.email),
|
|
1901
|
+
role: text(row.role),
|
|
1902
|
+
lastAccessAt: optionalText(row.last_access_at),
|
|
1903
|
+
lastSessionAccessAt: optionalText(row.last_session_access_at),
|
|
1904
|
+
activeSessionCount: number(row.active_session_count, 0),
|
|
1905
|
+
bannedAt: optionalText(row.banned_at),
|
|
1906
|
+
banExpiresAt: optionalText(row.ban_expires_at),
|
|
1907
|
+
deletedAt: optionalText(row.deleted_at),
|
|
1908
|
+
lastClientIp: optionalText(row.last_client_ip),
|
|
1909
|
+
lastDevice: optionalText(row.last_device),
|
|
1910
|
+
lastAppVersion: optionalText(row.last_app_version),
|
|
1911
|
+
lastUserAgent: optionalText(row.last_user_agent),
|
|
1912
|
+
};
|
|
1913
|
+
}
|
|
1914
|
+
function accountTargetState(target) {
|
|
1915
|
+
return {
|
|
1916
|
+
banned: Boolean(target.bannedAt),
|
|
1917
|
+
bannedAt: target.bannedAt,
|
|
1918
|
+
expiresAt: target.banExpiresAt,
|
|
1919
|
+
reason: target.banReason,
|
|
1920
|
+
bannedByUserId: target.bannedByUserId,
|
|
1921
|
+
};
|
|
1922
|
+
}
|
|
1923
|
+
function mergeContext(context, metadata) {
|
|
1924
|
+
return { request: context?.request, metadata: { ...context?.metadata, ...metadata } };
|
|
1925
|
+
}
|
|
1926
|
+
function futureTimestamp(value, name) {
|
|
1927
|
+
if (value === undefined)
|
|
1928
|
+
return undefined;
|
|
1929
|
+
const timestamp = Date.parse(value);
|
|
1930
|
+
if (!Number.isFinite(timestamp) || timestamp <= Date.now())
|
|
1931
|
+
throw new OperationsError("invalid", `${name} must be a future ISO timestamp`);
|
|
1932
|
+
return new Date(timestamp).toISOString();
|
|
1933
|
+
}
|
|
1934
|
+
function assertExportTransition(from, to) {
|
|
1935
|
+
const allowed = {
|
|
1936
|
+
pending: ["running", "failed", "cancelled"],
|
|
1937
|
+
running: ["complete", "failed", "cancelled"],
|
|
1938
|
+
complete: ["expired"],
|
|
1939
|
+
failed: [],
|
|
1940
|
+
cancelled: [],
|
|
1941
|
+
expired: [],
|
|
1942
|
+
};
|
|
1943
|
+
if (!allowed[from].includes(to))
|
|
1944
|
+
throw new OperationsError("conflict", `Cannot move a data export from ${from} to ${to}`);
|
|
1945
|
+
}
|
|
1946
|
+
function assertBackupTransition(from, to) {
|
|
1947
|
+
const allowed = {
|
|
1948
|
+
pending: ["running", "failed", "deleted"],
|
|
1949
|
+
running: ["complete", "failed", "deleted"],
|
|
1950
|
+
complete: ["deleted"],
|
|
1951
|
+
failed: ["deleted"],
|
|
1952
|
+
deleted: [],
|
|
1953
|
+
};
|
|
1954
|
+
if (!allowed[from].includes(to))
|
|
1955
|
+
throw new OperationsError("conflict", `Cannot move a backup from ${from} to ${to}`);
|
|
1956
|
+
}
|
|
1957
|
+
function json(value) {
|
|
1958
|
+
return value === undefined ? null : JSON.stringify(value);
|
|
1959
|
+
}
|
|
1960
|
+
function parseJson(value) {
|
|
1961
|
+
const raw = optionalText(value);
|
|
1962
|
+
if (raw === undefined)
|
|
1963
|
+
return undefined;
|
|
1964
|
+
try {
|
|
1965
|
+
return JSON.parse(raw);
|
|
1966
|
+
}
|
|
1967
|
+
catch {
|
|
1968
|
+
return undefined;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
function objectValue(value) {
|
|
1972
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
1973
|
+
? value
|
|
1974
|
+
: {};
|
|
1975
|
+
}
|
|
1976
|
+
function text(value) {
|
|
1977
|
+
if (typeof value === "string")
|
|
1978
|
+
return value;
|
|
1979
|
+
if (typeof value === "number" || typeof value === "bigint")
|
|
1980
|
+
return String(value);
|
|
1981
|
+
throw new Error("Expected database text value");
|
|
1982
|
+
}
|
|
1983
|
+
function optionalText(value) {
|
|
1984
|
+
return value === null || value === undefined ? undefined : text(value);
|
|
1985
|
+
}
|
|
1986
|
+
function number(value, fallback) {
|
|
1987
|
+
if (typeof value === "number")
|
|
1988
|
+
return value;
|
|
1989
|
+
if (typeof value === "bigint")
|
|
1990
|
+
return Number(value);
|
|
1991
|
+
if (typeof value === "string" && /^-?\d+$/.test(value))
|
|
1992
|
+
return Number(value);
|
|
1993
|
+
if (fallback !== undefined)
|
|
1994
|
+
return fallback;
|
|
1995
|
+
throw new Error("Expected database integer value");
|
|
1996
|
+
}
|
|
1997
|
+
function optionalNumber(value) {
|
|
1998
|
+
return value === null || value === undefined ? undefined : number(value);
|
|
1999
|
+
}
|
|
2000
|
+
function isUniqueConstraint(error) {
|
|
2001
|
+
let current = error;
|
|
2002
|
+
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
2003
|
+
const value = current;
|
|
2004
|
+
const details = `${String(value.code ?? "")} ${String(value.message ?? "")}`.toLowerCase();
|
|
2005
|
+
if (details.includes("unique") || details.includes("constraint"))
|
|
2006
|
+
return true;
|
|
2007
|
+
current = value.cause;
|
|
2008
|
+
}
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
//# sourceMappingURL=repository.js.map
|