mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-29 08:16:58 +08:00
refactor(server): clean architecture migration (hono-cf-clean-arch) (#433)
* refactor(server): rename routes/ to http/ (clean-arch step 1) The HTTP delivery layer was already split per-resource; align the directory name with the hono-cf-clean-arch standard. Pure mechanical move via git mv; updates the three server-side importers (app.ts, image-hosting-domain middleware, openapi/downloader). No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): add clean-arch backbone + migrate activity to a repo Introduce the composition root and dependency-injection seam: - usecases/ports.ts (barrel) + usecases/ports/<resource>.ts: framework-free port interfaces and DTOs - usecases/deps.ts: the Deps aggregate consumed via c.get('deps') - composition.ts: createDeps(platform) — the only place adapters are built - app.ts sets deps in request context after platform middleware First adapter: adapters/repos/activity.ts (ActivityRepo) replaces services/activity.ts. All 14 call sites rewired (routes use c.get('deps').activity.*; auth.ts and transitional services construct the repo from db). DTOs are now plain shapes, not drizzle $inferSelect. Behavior-preserving: typecheck + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageRepo + migration tracker services/storage.ts -> adapters/repos/storage.ts (StorageRepo). All 14 callers rewired (http/middleware via c.get('deps').storages.*; transitional services via createStorageRepo(db)). Port DTO reuses the shared Storage contract with Date timestamps; the S3-credential 'Storage' type alias across 9 files now points at StorageRecord. Data-layer test moved next to the repo. Adds docs/clean-arch-migration.md as the living progress tracker. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract Profile/Announcement/Notification repos - profile -> ProfileRepo; the pure buildBreadcrumb moves to domain/breadcrumb.ts - announcement -> AnnouncementRepo; notification -> NotificationRepo - All callers rewired (routes via c.get('deps').*; auth.ts + services via create<X>Repo(db)); data-layer tests moved next to their repos - Test infra: createApp accepts an optional deps; createTestApp returns deps so tests fake a port by spying on testApp.deps.* (events SSE failure test no longer spies the service module) typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract OrgRepo (authz) + InviteRepo - org -> OrgRepo (findPersonalOrg/getMemberRole/canReadOrg/canWriteToOrg/ isPersonalOrg); rewired across 4 routes + 2 auth middlewares + auth.ts - invite -> InviteRepo; rewired invite-codes route + auth.ts - data/unit tests for org & invite moved next to their repos typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract BackgroundJobRepo (+ BackgroundJobError to ports) background-jobs -> adapters/repos/background-job.ts. The BackgroundJobError (caught by http for status mapping) moves to usecases/ports per the standard. Rewired: background-jobs route + events SSE (deps) + archive-processing (transitional repo). Unit + data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract QuotaRepo from effective-quota The foundational quota leaf. effective-quota.ts -> adapters/repos/quota.ts (QuotaRepo); the pure currentTrafficPeriod moves to domain/quota.ts; DTOs (EffectiveQuota, CurrentStoragePlan) move to ports. Rewired 14 callers (http -> deps.quota; services/auth/entry-node/workers.scheduled -> createQuotaRepo). scheduled-worker test now mocks the adapter (createQuotaRepo) instead of the service module. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract TeamRepo + TeamInviteRepo team -> adapters/repos/team.ts (TeamRepo; composes QuotaRepo for quota totals); team-invite -> adapters/repos/team-invite.ts. teams-admin + teams routes use c.get('deps').{teams,teamInvites}. Data tests relocated. typecheck + lint + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * build(arch): enforce clean architecture via dependency-cruiser (ratchet) in CI Adds .dependency-cruiser.cjs with the full hono-cf-clean-arch rule set and wires pnpm lint:arch into CI. The drizzle-only-in-repos rule uses a shrinking MIGRATION_PENDING allowlist so it passes today while still enforcing every already-migrated layer; each future migration commit removes an entry. platform/ (Database driver type) and auth.ts are permanent named exceptions. Currently green: 222 modules / 926 deps, 0 violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): combine user + org-entitlements into UserAdminRepo Resolves the pre-existing user <-> org-entitlements import cycle by merging both into adapters/repos/user-admin.ts (UserAdminRepo); shared types (UserWithOrg, QuotaEntitlementItem, UserOperationFailure, entitlement inputs) move to ports. users + teams-admin routes use c.get('deps').userAdmin. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract SiteInvitationRepo site-invitations -> adapters/repos/site-invitations.ts. Route uses c.get('deps').siteInvitations; the email helper now receives siteName from the handler (http stays out of adapters); auth.ts uses the repo. Result-type unions moved to ports. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cf): fix storages.cf-test seed after StorageRepo extraction cf-tests are excluded from typecheck; biome had pruned the transiently-unused createStorageRepo import during the storage migration. Restore the import and convert the platform.db seed calls. test:cf green (57 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): introduce BDD-lite spec/ + spec<->test traceability lint Adds the standard's product-spec layer: - spec/*.feature (Gherkin, no Cucumber runner) — one per capability, scenarios tagged @<capability>/<slug> + layer; spec/README.md documents the convention - [spec: <id>] breadcrumbs on home tests - scripts/lint-spec.mjs + pnpm lint:spec (wired into CI): every scenario id must have a referencing test and every breadcrumb must match a scenario Specced: storages, announcements, notifications, invite-codes, site-invitations (41 scenarios, all traced). Specs grow per capability as the migration proceeds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract changelog + cf-custom-hostnames providers Establishes adapters/providers/. changelog (GitHub releases/CHANGELOG) and cf-custom-hostnames (CF for SaaS) move to adapters/providers/ behind ChangelogProvider / CfHostnamesProvider ports (CfConflictError -> ports). system + ihost-config routes use c.get('deps').{changelog,cfHostnames}. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move db-transaction -> db/, path-template -> lib/ Two framework-free utilities leave services/ for their proper homes: db/transaction.ts (the drizzle batch/transaction helper) and lib/path-template.ts (object-key builder). Importers updated. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate licensing subsystem drizzle to repos license-state -> adapters/repos/license-binding.ts (LicenseBindingRepo); instance-id + instance-info DB reads -> adapters/repos/instance.ts (InstanceRepo). licensing/ (has-feature, refresh, entitlement, instance-info) now uses the repos and imports no drizzle, so ^server/licensing leaves the dependency-cruiser ratchet. licensing-admin route uses c.get('deps').{licenseBinding,instance}; service callers construct the repos; instance-telemetry test mocks the adapter. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): move S3Service to adapters/gateways behind S3Gateway port Establishes adapters/gateways/ + deps.s3. S3Service -> adapters/gateways/s3.ts (implements S3Gateway; S3StorageCredentials -> ports). A thin services/s3.ts re-export shim keeps the http routes (objects/webdav/ihost/share-utils) and the 21 prototype-spy tests working unchanged until those routes migrate to deps.s3; s3-dependent services can now move to usecases using deps.s3. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from me route (avatar -> ProfileRepo) ProfileRepo gains setAvatar; the /api/me avatar handlers use c.get('deps').profiles instead of inline user-table updates. 'me' leaves the dependency-cruiser ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from quotas route (-> QuotaRepo.listOrgQuotaOverview) The admin quota-overview join moves into QuotaRepo; the route uses c.get('deps').quota. 'quotas' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): SystemOptionsRepo drains auth-providers/system/email-config routes New adapters/repos/system-options.ts (key-value access to systemOptions) + deps.systemOptions. auth-providers, system, email-config routes drop inline drizzle and use c.get('deps').systemOptions; all three leave the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from teams route (logo -> TeamRepo.setLogo) TeamRepo gains setLogo; teams route uses c.get('deps').teams for logo set/clear and drops its dead db locals. 'teams' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): drain inline drizzle from ihost-config (-> ImageHostingConfigRepo) New adapters/repos/image-hosting-config.ts + deps.imageHostingConfigs. The ihost-config route's custom-domain CRUD uses c.get('deps').imageHostingConfigs (cf-hostnames already via deps). 'ihost-config' leaves the ratchet. typecheck + lint + lint:arch + 3807 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): loadBindingState -> usecase, hasFeature/effectiveFeatures -> domain Finishes the feature-gate path: domain/licensing.ts (pure hasFeature/effectiveFeatures), usecases/licensing.ts (loadBindingState(deps) using LicenseBindingRepo + cert verify). licensing/has-feature.ts deleted. Rewired 10 callers (routes/middleware via c.get('deps'); services via createLicenseBindingRepo(db)). Tests retargeted to the new modules (domain + usecases licensing). typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): extract StorageUsageRepo + storage-usage reservation usecase The quota-reservation crown dependency. adapters/repos/storage-usage.ts (StorageUsageRepo: rollbackReservations + reconcile); usecases/storage-usage.ts (reserveStorageUsage/withStorageUsageReservation/StorageUsageMutationContext taking {quota,storageUsage} deps); StorageQuotaExceededError -> ports. Rewired 9 callers (objects/webdav/ihost routes via c.get('deps'); matter/image-hosting/archive/purge/ save-to-drive via constructed repos). Unblocks the matter/image-hosting clusters. typecheck + lint + lint:arch + 3807 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate 5 leaf service clusters to clean-arch (parallel wave) Extracted 7 services via parallel agents on file-disjoint components: - instance-telemetry -> usecases/instance-telemetry (reuses instance + systemOptions ports) - image-upload -> adapters/gateways/image-upload (ImageUpload port, deps.imageUpload) - archive-jobs -> adapters/gateways/archive-jobs (ArchiveJobsGateway, deps.archiveJobs) - zip-compress + zip-extract -> adapters/gateways/zip + adapters/repos/zip (ZipGateway + ZipPlanRepo) - object-upload-sessions -> adapters/repos/object-upload-session (ObjectUploadSessionRepo) - purge -> usecases/purge (pure usecase over existing s3/storages/storageUsage) Routes (objects/teams/me/internal/background-jobs) now reach these via c.get('deps'); entry files + workers build deps via createDeps(platform). Barrels wired by hand. typecheck + lint:arch (240 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add quotas/profile/licensing feature specs + traceability 29 new scenarios traced to existing integration tests via [spec: id] breadcrumbs. lint:spec: 70 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate auth/webdav/cloud/branding/image-hosting clusters (parallel wave 2) 17 services extracted via 5 parallel agents on file-disjoint components: - auth-account: email->EmailGateway, share-notification->ShareNotificationRepo, member-count->MemberCountRepo, captcha->domain+usecase, signup-mode/team-count->usecases - webdav-middleware: api-keys/download-tokens gateways, webdav-state/webdav-path repos, webdav-xml->domain (pure) - cloud: licensing-cloud->LicensingCloudGateway, cloud-store/cloud-traffic-report/ remote-download-usage repos (cloud-traffic-metering + licensing-refresh-runner folded in) - branding: pure usecase over existing deps (no new port) - image-hosting: ImageHostingRepo 12 new deps fields wired by hand. WebDavMatterRow DTO moved into the webdav-path port (was importing services/matter, which cycled through the ports barrel); domain WebDavMatter dirtype widened to number|null to match the nullable column. Ratchet shrunk: ihost.ts + middleware/image-hosting-domain.ts no longer touch drizzle. services/ now 26->9 (matter crown). typecheck + lint:arch (261 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add users/audit/teams/avatar/background-jobs/events/health specs 64 new scenarios traced to existing integration tests. lint:spec: 133 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate share/save-to-drive/archive-processing/trash-retention (parallel wave 3) - share -> ShareRepo (+ domain/share, transitional ShareMatterRow DTO); shares.ts now holds ZERO drizzle (dropped from the ratchet) - save-to-drive -> pure usecase over deps (s3/storages/storageUsage/quota/activity/share) - archive-processing -> usecase + ArchiveTargetFolderRepo (archive-jobs gateway self-assembles its deps subset from platform to avoid a composition cycle) - trash-retention -> pure usecase purge gains deps.share for share cascade-delete. 2 new deps fields wired. services/ now 9->5 (matter, matter-name-conflict, downloads, s3 shim, site-public-origin remain). typecheck + lint:arch (265 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add branding/email-config/auth-providers/system/image-hosting/webdav/quota-store specs 128 new scenarios traced to existing integration tests. lint:spec: 261 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the matter keystone + site-public-origin (wave 4) The crown. matter (644 lines, 17 exports) -> adapters/repos/matter.ts (MatterRepo: full drizzle CRUD + conflict resolution) + usecases/matter.ts (confirmUpload quota-guarded) + usecases/ports/matter.ts (Matter DTO + NameConflictError); matter-name-conflict -> domain. Fan-in of 10 rewired: objects/shares/trash routes now hold ZERO matter drizzle (via deps.matter); webdav + archive-processing/purge/save-to-drive/trash-retention usecases + zip/webdav-path repos repointed. site-public-origin -> domain (pure helpers) + usecase over deps.systemOptions. services/ now 5->2 (only downloads + the s3 shim remain). 1 new deps field (matter). typecheck + lint:arch (268 modules, no cycles) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add redirect + download-tasks specs 44 new scenarios traced to existing integration tests. lint:spec: 305 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate downloads (remote-download) cluster (wave 5) downloads/{core,mappers,types} (915 lines) -> adapters/repos/{downloader,download-task} (DownloaderRepo + DownloadTaskRepo) + usecases/downloads.ts (assignment + task state machine + remote-download credit billing) + usecases/ports/downloads.ts (DownloadError + DTOs). Rewired download-tasks/downloaders/events routes + objects.ts upload handlers to c.get('deps'). 2 new deps fields. services/ now down to ONLY the s3 shim. typecheck + lint:arch (268 modules) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): add shares spec (32 scenarios) lint:spec: 337 scenarios, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): delete the s3 shim — services/ is empty, clean-arch complete Routed all 20 S3 call-sites in http (objects/webdav routes + share-utils consumers shares/redirect/ihost/image-hosting-domain) onto c.get('deps').s3; webdav's no-c helpers take an S3Gateway param. Repointed 17 test files off the shim onto adapters/gateways/s3. Deleted server/services/s3.ts — server/services/ is now empty and gone. Ratchet: dropped ^server/services (fully migrated); no-circular now fully enforced with no path exemptions. MIGRATION_PENDING is down to 2 deliberately-deferred files (http/webdav.ts listDescendants, middleware/auth.ts session lookup). Also adds the objects spec (39 scenarios) -> 376 scenarios across 26 capabilities. Final gates: typecheck + lint:arch (267 modules, no cycles) + lint:spec (376) + lint + 3810 tests + 57 cf-tests all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): migrate the last 2 ratchet files — architecture fully locked webdav.ts + middleware/auth.ts were the last files touching drizzle outside repos. - WebDAV: listDescendants/PROPPATCH-touch/PUT-overwrite/COPY-rollback + Basic-Auth username check moved to MatterRepo.{listActiveDescendants,trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesUsername}. webdav.ts now imports no drizzle. - Auth middleware: disabled-user (banned) check -> deps.userAdmin.isBanned. Ratchet (MIGRATION_PENDING) is now empty and removed. no-circular + drizzle-only-in-repos are fully enforced with zero exemptions; only platform/, test/, auth.ts remain as permanent named exceptions. New methods covered by existing real-D1 webdav/auth integration tests. typecheck + lint:arch (267 modules) + lint:spec (376) + lint + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(spec): spec the 4 remaining admin/auth capabilities Closes the spec gaps for capabilities that had routes+tests but no .feature: image-hosting-config (domain/CF custom-hostname admin), licensing-admin (cloud pairing/binding/refresh), teams-admin (team admin + entitlements), auth-username (username sign-up). 42 new scenarios traced to existing integration tests. lint:spec: 418 scenarios across 30 capabilities, all covered. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(matter): listActiveDescendants uses exact-prefix (SUBSTR) not LIKE Folder names can contain '_'/'%', which LIKE treats as wildcards and would over-match descendants in WebDAV recursive COPY/MOVE. Reuse the repo's existing descendantParentCondition (SUBSTR), consistent with getDescendants/cascadeParentPath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): address review follow-ups (DTO dedupe, composition, dead locals) - Dedupe transitional DTOs: ShareMatterRow + WebDavMatterRow -> the canonical Matter port DTO (removes hand-copied duplicates + schema-drift risk; no cycle reintroduced). - composition.ts: hoist shared stateless instances (one s3/storages/systemOptions instead of constructing duplicates inline). - Remove the 21 dead 'const db = c.get(platform).db' locals -> biome warning-free. typecheck + lint:arch (267 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(server): dissolve server/licensing into domain + usecases layers server/licensing/ was a feature-grouped dir outside the layer taxonomy — its 3 orchestration files imported adapters directly, escaping usecases-no-infrastructure. Now classified + enforced: - public-keys -> domain/license-keys (pure) - verify + cloud-event-token -> usecases/license-certificate (paseto/zod crypto helpers) - entitlement/instance-info/refresh -> deps-first usecases (license-entitlement, instance-info, license-refresh), using existing deps.{licenseBinding,instance,licensingCloud} 11 consumers rewired to deps; dead db param dropped from runLicensingRefresh. No barrel changes. server/licensing/ deleted — every server file now sits in an enforced layer (or a named exception: platform/test/auth.ts/lib/middleware). typecheck + lint:arch (266 modules) + lint:spec (418) + 3810 tests + 57 cf-tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Architecture enforcement for the hono-cf-clean-arch layout.
|
||||
*
|
||||
* pnpm lint:arch
|
||||
*
|
||||
* The hono-cf-clean-arch migration is COMPLETE (see docs/clean-arch-migration.md):
|
||||
* server/services/ is gone, every route uses `c.get('deps')`, and the ratchet that
|
||||
* tracked not-yet-migrated drizzle importers is empty and removed. All rules below
|
||||
* are fully enforced with no migration allowlist.
|
||||
*
|
||||
* Permanent exceptions to `drizzle-only-in-repos`:
|
||||
* - server/db : the schema + client live here by definition
|
||||
* - server/platform : the runtime DB/env/binding abstraction owns the
|
||||
* `Database` driver type
|
||||
* - server/auth.ts : better-auth owns its own tables and serves requests
|
||||
* - server/test : test harness/helpers (the suites are exempt anyway)
|
||||
*/
|
||||
|
||||
// MIGRATION COMPLETE — the ratchet is empty and gone. Persistence is confined to
|
||||
// adapters/repos/ + db/, plus three permanent exceptions: platform/ (owns the
|
||||
// `Database` driver type), the test harness, and auth.ts (better-auth owns its own
|
||||
// tables and handles raw requests). The architecture is now fully locked.
|
||||
const DRIZZLE_ALLOWED = `^server/(adapters/repos|db|platform|test)|^server/auth\\.ts`
|
||||
|
||||
/** @type {import('dependency-cruiser').IConfiguration} */
|
||||
module.exports = {
|
||||
forbidden: [
|
||||
{
|
||||
name: 'no-circular',
|
||||
// Fully enforced: the legacy services/ dir (which carried a pre-existing
|
||||
// user <-> org-entitlements cycle) is migrated and gone, so no path is exempt.
|
||||
severity: 'error',
|
||||
from: {},
|
||||
to: { circular: true },
|
||||
},
|
||||
{
|
||||
name: 'domain-stays-pure',
|
||||
comment: 'domain/ may only import domain/ and shared/. No frameworks, no I/O.',
|
||||
severity: 'error',
|
||||
from: { path: '^server/domain' },
|
||||
to: { pathNot: '^server/domain|^shared' },
|
||||
},
|
||||
{
|
||||
name: 'usecases-no-infrastructure',
|
||||
comment: 'usecases/ must not reach outward to adapters, http, db, or composition.',
|
||||
severity: 'error',
|
||||
from: { path: '^server/usecases' },
|
||||
to: { path: '^server/(adapters|http|db)|^server/composition' },
|
||||
},
|
||||
{
|
||||
name: 'usecases-no-framework-packages',
|
||||
comment: 'usecases/ must not import delivery or persistence frameworks.',
|
||||
severity: 'error',
|
||||
from: { path: '^server/usecases' },
|
||||
to: { path: 'node_modules/(hono|drizzle-orm|better-auth)' },
|
||||
},
|
||||
{
|
||||
name: 'adapters-not-into-delivery',
|
||||
comment: 'adapters/ implement ports; they never know about http/ or composition.',
|
||||
severity: 'error',
|
||||
from: { path: '^server/adapters' },
|
||||
to: { path: '^server/(http|composition)' },
|
||||
},
|
||||
{
|
||||
name: 'drizzle-only-in-repos',
|
||||
comment: 'Persistence is confined to adapters/repos/ and db/ (+ ratchet allowlist).',
|
||||
severity: 'error',
|
||||
from: { path: '^server', pathNot: DRIZZLE_ALLOWED },
|
||||
to: { path: 'node_modules/drizzle-orm|^server/db/(schema|auth-schema)' },
|
||||
},
|
||||
{
|
||||
name: 'http-not-into-adapters',
|
||||
comment: 'http/ gets dependencies from context, never constructs adapters.',
|
||||
severity: 'error',
|
||||
from: { path: '^server/http' },
|
||||
to: { path: '^server/adapters' },
|
||||
},
|
||||
{
|
||||
name: 'shared-is-a-leaf',
|
||||
comment: 'shared/ is the contract; it imports nothing from server/ or src/.',
|
||||
severity: 'error',
|
||||
from: { path: '^shared' },
|
||||
to: { path: '^server|^src' },
|
||||
},
|
||||
{
|
||||
name: 'frontend-not-into-server',
|
||||
comment: 'The SPA talks to the server over HTTP only.',
|
||||
severity: 'error',
|
||||
from: { path: '^src' },
|
||||
to: { path: '^server' },
|
||||
},
|
||||
{
|
||||
name: 'server-not-into-frontend',
|
||||
comment: 'The server never reaches into the SPA; the two halves meet only through shared/.',
|
||||
severity: 'error',
|
||||
from: { path: '^server' },
|
||||
to: { path: '^src' },
|
||||
},
|
||||
],
|
||||
options: {
|
||||
doNotFollow: { path: 'node_modules' },
|
||||
exclude: { path: ['\\.(test|spec)\\.[jt]sx?$', '\\.(integration|cf-test|libsql-test)\\.[jt]sx?$', '\\.gen\\.[jt]s$'] },
|
||||
tsConfig: { fileName: 'tsconfig.depcruise.json' },
|
||||
tsPreCompilationDeps: true,
|
||||
},
|
||||
}
|
||||
@@ -55,6 +55,8 @@ jobs:
|
||||
cache-dependency-path: cmd/go.sum
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm lint
|
||||
- run: pnpm lint:arch
|
||||
- run: pnpm lint:spec
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm openapi:downloader:check
|
||||
- run: pnpm exec vitest run --project unit --coverage --coverage.reportsDirectory=coverage/unit
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Clean Architecture Migration (hono-cf-clean-arch)
|
||||
|
||||
Living tracker for migrating `server/` to the canonical layout: `domain → usecases
|
||||
→ adapters → http`, with a `composition.ts` root and `dependency-cruiser`
|
||||
enforcement. Strangler-style: **every commit is behavior-preserving and leaves
|
||||
`pnpm typecheck` + `pnpm test` green.** One PR, many green commits.
|
||||
|
||||
## Target layout
|
||||
|
||||
```
|
||||
server/
|
||||
domain/ pure business rules (no node_modules except shared/)
|
||||
usecases/ application operations; take `deps` first
|
||||
ports.ts barrel re-exporting ports/<resource>.ts
|
||||
ports/ framework-free port interfaces + DTOs (one file per resource)
|
||||
deps.ts the Deps aggregate
|
||||
adapters/
|
||||
repos/ drizzle repositories (the ONLY place schema/drizzle is imported)
|
||||
gateways/ external services that aren't HTTP-API providers (s3, email, …)
|
||||
providers/ external HTTP API clients
|
||||
http/ hono routes (already split per resource); get deps from context
|
||||
auth.ts better-auth (named drizzle exception)
|
||||
composition.ts createDeps(platform): the only place adapters are constructed
|
||||
db/ drizzle schema + client
|
||||
```
|
||||
|
||||
## Conventions (the recipe applied per resource)
|
||||
|
||||
1. Port: `usecases/ports/<r>.ts` — plain DTOs + repo/gateway interface. No drizzle,
|
||||
no zod runtime (type-only shared imports OK). Add `export * from './ports/<r>'`
|
||||
to `usecases/ports.ts`.
|
||||
2. Adapter: `adapters/repos/<r>.ts` — `create<R>Repo(db): <R>Repo`. Maps rows → DTOs.
|
||||
3. Wire: add to `usecases/deps.ts` (the `Deps` interface) and `composition.ts`.
|
||||
4. Rewire callers:
|
||||
- `http/` + middleware → `c.get('deps').<r>.<method>(...)`
|
||||
- not-yet-migrated `services/` / `auth.ts` → `create<R>Repo(db).<method>(...)`
|
||||
(transitional; removed when that service itself migrates)
|
||||
5. Delete the old `services/<r>.ts`; move its co-located data tests next to the repo.
|
||||
6. `pnpm typecheck && pnpm lint && pnpm test` green; commit.
|
||||
|
||||
Imports: relative within `server/` (matches existing code); `@shared/*` for shared.
|
||||
|
||||
## Progress
|
||||
|
||||
### Done
|
||||
- [x] **Step 1** `routes/ → http/` rename
|
||||
- [x] **Backbone** `composition.ts` + `usecases/deps.ts` + `usecases/ports/` + deps middleware
|
||||
- [x] `activity` → `adapters/repos/activity.ts` (ActivityRepo)
|
||||
- [x] `storage` → `adapters/repos/storage.ts` (StorageRepo)
|
||||
- [x] `profile` → ProfileRepo; `buildBreadcrumb` → `domain/breadcrumb.ts`
|
||||
- [x] `announcement` → AnnouncementRepo
|
||||
- [x] `notification` → NotificationRepo
|
||||
- [x] **test infra**: `createApp(platform, auth, deps?)` + `createTestApp` returns
|
||||
`deps`, so tests fake a port by spying on `testApp.deps.<x>` (replaces
|
||||
cross-boundary module spies, e.g. events SSE unread-count failure)
|
||||
|
||||
- [x] `org` → OrgRepo (authz queries) · `invite` → InviteRepo
|
||||
- [x] `background-jobs` → BackgroundJobRepo (+ BackgroundJobError to ports)
|
||||
- [x] `effective-quota` → QuotaRepo (`currentTrafficPeriod` → domain/quota); unblocks
|
||||
team / storage-usage / matter / cloud-traffic-metering. 14 callers + entry-node
|
||||
+ workers/scheduled rewired.
|
||||
|
||||
- [x] `team` → TeamRepo (uses QuotaRepo internally) · `team-invite` → TeamInviteRepo
|
||||
- [x] `site-invitations` → SiteInvitationRepo · `cf-custom-hostnames` → CfHostnamesProvider
|
||||
· `changelog` → ChangelogProvider · `instance`/license-binding → InstanceRepo/LicenseBindingRepo
|
||||
· `s3` → S3Gateway (shim) · `system-options` → SystemOptionsRepo · `image-hosting-config` → ImageHostingConfigRepo
|
||||
- [x] `user` + `org-entitlements` -> UserAdminRepo (combined; resolves the user/org-entitlements cycle)
|
||||
- [x] `storage-usage` → StorageUsageRepo + reserve/withReservation usecase (the quota-reservation crown foundation)
|
||||
|
||||
### Parallel migration waves (file-disjoint components migrated concurrently by subagents; barrels wired by the orchestrator)
|
||||
All waves done. Each wave: agents migrated disjoint components (new ports/adapters/usecases/domain,
|
||||
rewired callers to `c.get('deps')`), the orchestrator wired the 3 barrels and ran the gates.
|
||||
- [x] **Wave 1** — instance-telemetry, image-upload, archive-jobs, zip-compress/extract, object-upload-sessions, purge
|
||||
- [x] **Wave 2** — auth-account (signup-mode-guard, team-count-guard, captcha, email, share-notification),
|
||||
webdav-middleware (download-tokens, api-keys, webdav-state/path/xml), cloud (licensing-cloud, cloud-store,
|
||||
cloud-traffic-metering→cloud-traffic-report, remote-download-usage, licensing-refresh-runner), branding, image-hosting
|
||||
- [x] **Wave 3** — share + save-to-drive, archive-processing (+archive-target-folder), trash-retention
|
||||
- [x] **Wave 4** — the **matter keystone** (MatterRepo + matter usecase + matter-name-conflict→domain) + site-public-origin
|
||||
- [x] **Wave 5** — downloads (DownloaderRepo + DownloadTaskRepo + state-machine usecase); then the **s3 shim deleted**
|
||||
|
||||
### Status: COMPLETE — architecture fully locked
|
||||
`server/services/` is empty and removed. Every drizzle access lives in `adapters/repos/`; every route
|
||||
gets its dependencies from `c.get('deps')`. **The ratchet is empty and removed** — including the final two
|
||||
files: `http/webdav.ts` (listDescendants/proppatch/PUT-overwrite/Basic-Auth → MatterRepo.{listActiveDescendants,
|
||||
trashByIds,restoreActiveByIds,touch,applyUpload} + UserAdminRepo.{isBanned,matchesUsername}) and
|
||||
`middleware/auth.ts` (disabled-user check → UserAdminRepo.isBanned). Gates green: `typecheck`,
|
||||
`lint:arch` (267 modules, **no-circular + drizzle-only-in-repos fully enforced, zero exemptions**),
|
||||
`lint:spec`, `lint`, `test` (3810), `test:cf` (57).
|
||||
|
||||
### Enforcement — DONE, fully locked
|
||||
- [x] `.dependency-cruiser.cjs` + `lint:arch` in CI. All clean-arch rules active with **no migration allowlist**.
|
||||
- [x] `platform/` (Database driver type), `server/test`, and `auth.ts` (better-auth owns its tables) are the only
|
||||
permanent named exceptions to `drizzle-only-in-repos`.
|
||||
|
||||
### Product specs (BDD-lite) — DONE
|
||||
- [x] `spec/` Gherkin `.feature` (one per capability) + `spec/README.md`; `[spec: <id>]` breadcrumbs on home
|
||||
tests; `pnpm lint:spec` (CI) enforces traceability both ways.
|
||||
- [x] **418 scenarios across 30 capabilities**: storages, announcements, notifications, invite-codes,
|
||||
site-invitations, quotas, profile, licensing, users, audit, teams, avatar, background-jobs, events, health,
|
||||
branding, email-config, auth-providers, system, image-hosting, webdav, quota-store, redirect, download-tasks,
|
||||
shares, objects, image-hosting-config, licensing-admin, teams-admin, auth-username.
|
||||
|
||||
### Structure cleanup — DONE
|
||||
- [x] Dissolved the unclassified `server/licensing/` feature dir into the layers (it had escaped
|
||||
`domain-stays-pure` / `usecases-no-infrastructure`): `public-keys`→`domain/license-keys`;
|
||||
`verify`+`cloud-event-token`→`usecases/license-certificate`; `entitlement`/`instance-info`/`refresh`→
|
||||
deps-first usecases (`license-entitlement`/`instance-info`/`license-refresh`, using existing
|
||||
`deps.{licenseBinding,instance,licensingCloud}` — no barrel changes). 11 consumers rewired to `deps`.
|
||||
Remaining non-layer dirs are intentional: `platform/`+`test/`+`auth.ts` (named exceptions),
|
||||
`lib/` (framework-free utils), `middleware/` (Hono delivery convention).
|
||||
|
||||
### Post-review follow-ups — DONE
|
||||
- [x] Deduped the transitional matter-row DTOs: `ShareMatterRow` / `WebDavMatterRow` now reference the
|
||||
canonical `Matter` port DTO (removed the hand-copied duplicates + their schema-drift risk).
|
||||
- [x] Hoisted shared stateless instances in `composition.ts` (single `s3` / `storages` / `systemOptions`).
|
||||
- [x] Removed the 21 dead `const db = c.get('platform').db` locals — biome is now warning-free.
|
||||
- [x] `MatterRepo.listActiveDescendants` uses SUBSTR exact-prefix (not LIKE) — correctness for names with `_`/`%`.
|
||||
@@ -31,6 +31,8 @@
|
||||
"test:watch": "vitest --project unit --project integration",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check --write .",
|
||||
"lint:arch": "depcruise server/ shared/ --config .dependency-cruiser.cjs",
|
||||
"lint:spec": "node scripts/lint-spec.mjs",
|
||||
"prepare": "husky",
|
||||
"format": "biome format --write .",
|
||||
"e2e": "playwright test",
|
||||
@@ -115,6 +117,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"@vitest/coverage-v8": "^4.1.2",
|
||||
"dependency-cruiser": "^17.4.3",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.0.2",
|
||||
|
||||
Generated
+255
@@ -224,6 +224,9 @@ importers:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.1.2
|
||||
version: 4.1.4(vitest@4.1.4)
|
||||
dependency-cruiser:
|
||||
specifier: ^17.4.3
|
||||
version: 17.4.3
|
||||
drizzle-kit:
|
||||
specifier: ^0.31.10
|
||||
version: 0.31.10
|
||||
@@ -3318,6 +3321,22 @@ packages:
|
||||
'@vitest/utils@4.1.4':
|
||||
resolution: {integrity: sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==}
|
||||
|
||||
acorn-jsx-walk@2.0.0:
|
||||
resolution: {integrity: sha512-uuo6iJj4D4ygkdzd6jPtcxs8vZgDX9YFIkqczGImoypX2fQ4dVImmu3UzA4ynixCIMTrEOWW+95M2HuBaCEOVA==}
|
||||
|
||||
acorn-jsx@5.3.2:
|
||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||
peerDependencies:
|
||||
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
|
||||
acorn-loose@8.5.2:
|
||||
resolution: {integrity: sha512-PPvV6g8UGMGgjrMu+n/f9E/tCSkNQ2Y97eFvuVdJfG11+xdIeDcLyNdC8SHcrHbRqkfwLASdplyR6B6sKM1U4A==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -3335,6 +3354,10 @@ packages:
|
||||
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@5.2.0:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3536,6 +3559,10 @@ packages:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
character-entities-html4@2.1.0:
|
||||
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
|
||||
|
||||
@@ -3580,6 +3607,13 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
colorette@2.0.20:
|
||||
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
|
||||
|
||||
@@ -3665,6 +3699,11 @@ packages:
|
||||
defu@6.1.7:
|
||||
resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
|
||||
|
||||
dependency-cruiser@17.4.3:
|
||||
resolution: {integrity: sha512-L4GLuAvmXevWnPCIaFfOz6eD92c+yY+pDgVqgufrLDnW3xYA799CSZQlly2r2N13nhAlnZY6VzY7Rx5pHNvk2w==}
|
||||
engines: {node: ^20.12||^22||>=24}
|
||||
hasBin: true
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -3818,6 +3857,10 @@ packages:
|
||||
error-stack-parser-es@1.0.5:
|
||||
resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==}
|
||||
|
||||
es-errors@1.3.0:
|
||||
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
@@ -3932,6 +3975,9 @@ packages:
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
function-bind@1.1.2:
|
||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -3957,6 +4003,10 @@ packages:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
global-directory@4.0.1:
|
||||
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
@@ -3964,6 +4014,10 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
hasown@2.0.4:
|
||||
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==}
|
||||
|
||||
@@ -4048,15 +4102,27 @@ packages:
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
ignore@7.0.5:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
ini@4.1.1:
|
||||
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
|
||||
inline-style-parser@0.2.7:
|
||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||
|
||||
interpret@3.1.1:
|
||||
resolution: {integrity: sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
is-alphabetical@2.0.1:
|
||||
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
|
||||
|
||||
@@ -4070,6 +4136,10 @@ packages:
|
||||
is-buffer@1.1.6:
|
||||
resolution: {integrity: sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==}
|
||||
|
||||
is-core-module@2.16.2:
|
||||
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-decimal@2.0.1:
|
||||
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
|
||||
|
||||
@@ -4088,10 +4158,18 @@ packages:
|
||||
is-hexadecimal@2.0.1:
|
||||
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
|
||||
|
||||
is-installed-globally@1.0.0:
|
||||
resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-number@7.0.0:
|
||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||
engines: {node: '>=0.12.0'}
|
||||
|
||||
is-path-inside@4.0.0:
|
||||
resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-plain-obj@4.1.0:
|
||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -4154,6 +4232,10 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
kleur@3.0.3:
|
||||
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
kleur@4.1.5:
|
||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4597,6 +4679,9 @@ packages:
|
||||
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-parse@1.0.7:
|
||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||
|
||||
path-posix@1.0.0:
|
||||
resolution: {integrity: sha512-1gJ0WpNIiYcQydgg3Ed8KzvIqTsDpNwq+cjBCssvBtuTWjEqY1AW+i+OepiEMqDCzyro9B2sLAe4RBPajMYFiA==}
|
||||
|
||||
@@ -4691,6 +4776,10 @@ packages:
|
||||
promise-limit@2.7.0:
|
||||
resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
@@ -4829,6 +4918,10 @@ packages:
|
||||
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
rechoir@0.8.0:
|
||||
resolution: {integrity: sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==}
|
||||
engines: {node: '>= 10.13.0'}
|
||||
|
||||
refractor@5.0.0:
|
||||
resolution: {integrity: sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==}
|
||||
|
||||
@@ -4841,6 +4934,10 @@ packages:
|
||||
regex@6.1.0:
|
||||
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
|
||||
|
||||
regexp-tree@0.1.27:
|
||||
resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==}
|
||||
hasBin: true
|
||||
|
||||
rehype-attr@4.0.2:
|
||||
resolution: {integrity: sha512-v4+gw7pvUVLbG/dUpLgBE6r3TWTBYJ7z+sfAH3zapmM5CKzk5+CopFQgr4gMR6OBSKl/qpI6HR7gv1Cbig0uow==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -4904,6 +5001,11 @@ packages:
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
resolve@1.22.12:
|
||||
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4942,6 +5044,9 @@ packages:
|
||||
safe-buffer@5.2.1:
|
||||
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
|
||||
|
||||
safe-regex@2.1.1:
|
||||
resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==}
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
@@ -4996,6 +5101,9 @@ packages:
|
||||
resolution: {integrity: sha512-5KbjcP456Gm1lrk+rhDuX4zFri+3lRX39IjzXAvoMAO8Ne76WlVlM+Z3kA6jdZ7+QHadgXsf++R7g2jaYVbYig==}
|
||||
engines: {node: '>=0.12.18'}
|
||||
|
||||
sisteransi@1.0.5:
|
||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5056,6 +5164,10 @@ packages:
|
||||
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
strip-bom@3.0.0:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -5082,6 +5194,10 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0:
|
||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
@@ -5168,6 +5284,14 @@ packages:
|
||||
ts-interface-checker@0.1.13:
|
||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||
|
||||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
resolution: {integrity: sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
tsconfig-paths@4.2.0:
|
||||
resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -5389,6 +5513,11 @@ packages:
|
||||
warning@4.0.3:
|
||||
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||
|
||||
watskeburt@5.0.3:
|
||||
resolution: {integrity: sha512-g9CXukMjazlJJVQ3OHzXsnG25KFYgSgKMIyoJrD8ggr0DbS9UNF7OzIqWmmKKBMedkxj3T01uqEaGnn+y7QhMA==}
|
||||
engines: {node: ^20.12||^22.13||>=24.0}
|
||||
hasBin: true
|
||||
|
||||
web-namespaces@2.0.1:
|
||||
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
|
||||
|
||||
@@ -8406,6 +8535,20 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
acorn-jsx-walk@2.0.0: {}
|
||||
|
||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn-loose@8.5.2:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn-walk@8.3.5:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
ansi-escapes@7.3.0:
|
||||
@@ -8416,6 +8559,10 @@ snapshots:
|
||||
|
||||
ansi-regex@6.2.2: {}
|
||||
|
||||
ansi-styles@4.3.0:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi-styles@6.2.3: {}
|
||||
@@ -8576,6 +8723,11 @@ snapshots:
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
character-entities-html4@2.1.0: {}
|
||||
|
||||
character-entities-legacy@3.0.0: {}
|
||||
@@ -8621,6 +8773,12 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
colorette@2.0.20: {}
|
||||
|
||||
comma-separated-tokens@2.0.3: {}
|
||||
@@ -8685,6 +8843,27 @@ snapshots:
|
||||
|
||||
defu@6.1.7: {}
|
||||
|
||||
dependency-cruiser@17.4.3:
|
||||
dependencies:
|
||||
acorn: 8.16.0
|
||||
acorn-jsx: 5.3.2(acorn@8.16.0)
|
||||
acorn-jsx-walk: 2.0.0
|
||||
acorn-loose: 8.5.2
|
||||
acorn-walk: 8.3.5
|
||||
commander: 14.0.3
|
||||
enhanced-resolve: 5.22.1
|
||||
ignore: 7.0.5
|
||||
interpret: 3.1.1
|
||||
is-installed-globally: 1.0.0
|
||||
json5: 2.2.3
|
||||
picomatch: 4.0.4
|
||||
prompts: 2.4.2
|
||||
rechoir: 0.8.0
|
||||
safe-regex: 2.1.1
|
||||
semver: 7.8.1
|
||||
tsconfig-paths-webpack-plugin: 4.2.0
|
||||
watskeburt: 5.0.3
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.0.2: {}
|
||||
@@ -8740,6 +8919,8 @@ snapshots:
|
||||
|
||||
error-stack-parser-es@1.0.5: {}
|
||||
|
||||
es-errors@1.3.0: {}
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
esbuild@0.18.20:
|
||||
@@ -8933,6 +9114,8 @@ snapshots:
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
function-bind@1.1.2: {}
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-east-asian-width@1.6.0: {}
|
||||
@@ -8951,10 +9134,18 @@ snapshots:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
|
||||
global-directory@4.0.1:
|
||||
dependencies:
|
||||
ini: 4.1.1
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
hasown@2.0.4:
|
||||
dependencies:
|
||||
function-bind: 1.1.2
|
||||
|
||||
hast-util-from-html@2.0.3:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -9119,12 +9310,18 @@ snapshots:
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
ini@4.1.1: {}
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
interpret@3.1.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
|
||||
is-alphanumerical@2.0.1:
|
||||
@@ -9138,6 +9335,10 @@ snapshots:
|
||||
|
||||
is-buffer@1.1.6: {}
|
||||
|
||||
is-core-module@2.16.2:
|
||||
dependencies:
|
||||
hasown: 2.0.4
|
||||
|
||||
is-decimal@2.0.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
@@ -9152,8 +9353,15 @@ snapshots:
|
||||
|
||||
is-hexadecimal@2.0.1: {}
|
||||
|
||||
is-installed-globally@1.0.0:
|
||||
dependencies:
|
||||
global-directory: 4.0.1
|
||||
is-path-inside: 4.0.0
|
||||
|
||||
is-number@7.0.0: {}
|
||||
|
||||
is-path-inside@4.0.0: {}
|
||||
|
||||
is-plain-obj@4.1.0: {}
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
@@ -9215,6 +9423,8 @@ snapshots:
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
kleur@3.0.3: {}
|
||||
|
||||
kleur@4.1.5: {}
|
||||
|
||||
kysely@0.28.17: {}
|
||||
@@ -9848,6 +10058,8 @@ snapshots:
|
||||
|
||||
path-expression-matcher@1.5.0: {}
|
||||
|
||||
path-parse@1.0.7: {}
|
||||
|
||||
path-posix@1.0.0: {}
|
||||
|
||||
path-to-regexp@6.3.0: {}
|
||||
@@ -9929,6 +10141,11 @@ snapshots:
|
||||
|
||||
promise-limit@2.7.0: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
sisteransi: 1.0.5
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
@@ -10129,6 +10346,10 @@ snapshots:
|
||||
tiny-invariant: 1.3.3
|
||||
tslib: 2.8.1
|
||||
|
||||
rechoir@0.8.0:
|
||||
dependencies:
|
||||
resolve: 1.22.12
|
||||
|
||||
refractor@5.0.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -10146,6 +10367,8 @@ snapshots:
|
||||
dependencies:
|
||||
regex-utilities: 2.3.0
|
||||
|
||||
regexp-tree@0.1.27: {}
|
||||
|
||||
rehype-attr@4.0.2:
|
||||
dependencies:
|
||||
unified: 11.0.5
|
||||
@@ -10260,6 +10483,13 @@ snapshots:
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
resolve@1.22.12:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
is-core-module: 2.16.2
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
dependencies:
|
||||
onetime: 7.0.0
|
||||
@@ -10317,6 +10547,10 @@ snapshots:
|
||||
|
||||
safe-buffer@5.2.1: {}
|
||||
|
||||
safe-regex@2.1.1:
|
||||
dependencies:
|
||||
regexp-tree: 0.1.27
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
@@ -10391,6 +10625,8 @@ snapshots:
|
||||
|
||||
simple-icons@16.18.0: {}
|
||||
|
||||
sisteransi@1.0.5: {}
|
||||
|
||||
slice-ansi@7.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 6.2.3
|
||||
@@ -10449,6 +10685,8 @@ snapshots:
|
||||
dependencies:
|
||||
ansi-regex: 6.2.2
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strnum@2.3.0: {}
|
||||
@@ -10477,6 +10715,8 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
supports-preserve-symlinks-flag@1.0.0: {}
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
@@ -10553,6 +10793,19 @@ snapshots:
|
||||
|
||||
ts-interface-checker@0.1.13: {}
|
||||
|
||||
tsconfig-paths-webpack-plugin@4.2.0:
|
||||
dependencies:
|
||||
chalk: 4.1.2
|
||||
enhanced-resolve: 5.22.1
|
||||
tapable: 2.3.3
|
||||
tsconfig-paths: 4.2.0
|
||||
|
||||
tsconfig-paths@4.2.0:
|
||||
dependencies:
|
||||
json5: 2.2.3
|
||||
minimist: 1.2.8
|
||||
strip-bom: 3.0.0
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsup@8.5.1(@swc/core@1.15.40)(jiti@2.7.0)(postcss@8.5.15)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0):
|
||||
@@ -10766,6 +11019,8 @@ snapshots:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
|
||||
watskeburt@5.0.3: {}
|
||||
|
||||
web-namespaces@2.0.1: {}
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env node
|
||||
// Spec ↔ test traceability governance lint (NOT a behavioural test runner).
|
||||
//
|
||||
// Every scenario in spec/**/*.feature is tagged `@<capability>/<slug>`. Its home
|
||||
// test carries `[spec: <capability>/<slug>]` in the test name. This lint enforces
|
||||
// the link both ways:
|
||||
// - ERROR: a spec scenario id with no referencing test (spec drift / missing coverage)
|
||||
// - ERROR: a `[spec: id]` breadcrumb whose id has no scenario (stale reference)
|
||||
// See spec/README.md.
|
||||
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const ROOT = process.cwd()
|
||||
const SPEC_DIR = join(ROOT, 'spec')
|
||||
const TEST_DIRS = ['server', 'src', 'shared', 'e2e']
|
||||
const TEST_RE = /\.(test|integration\.test|cf-test|libsql-test|spec)\.[jt]sx?$/
|
||||
const ID_RE = /@([a-z0-9-]+\/[a-z0-9-]+)\b/g
|
||||
const REF_RE = /\[spec:\s*([a-z0-9-]+\/[a-z0-9-]+)\s*\]/g
|
||||
|
||||
function walk(dir, onFile) {
|
||||
let entries
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const name of entries) {
|
||||
if (name === 'node_modules' || name === '.git') continue
|
||||
const full = join(dir, name)
|
||||
const st = statSync(full)
|
||||
if (st.isDirectory()) walk(full, onFile)
|
||||
else onFile(full)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Collect scenario ids declared in the specs.
|
||||
const specIds = new Map() // id -> feature file
|
||||
walk(SPEC_DIR, (file) => {
|
||||
if (!file.endsWith('.feature')) return
|
||||
const text = readFileSync(file, 'utf8')
|
||||
for (const line of text.split('\n')) {
|
||||
// Only tag lines (start with @ after trim) carry scenario ids.
|
||||
if (!line.trim().startsWith('@')) continue
|
||||
for (const m of line.matchAll(ID_RE)) specIds.set(m[1], file)
|
||||
}
|
||||
})
|
||||
|
||||
// 2. Collect [spec: id] breadcrumbs from test files.
|
||||
const refIds = new Map() // id -> [files]
|
||||
for (const d of TEST_DIRS) {
|
||||
walk(join(ROOT, d), (file) => {
|
||||
if (!TEST_RE.test(file)) return
|
||||
const text = readFileSync(file, 'utf8')
|
||||
for (const m of text.matchAll(REF_RE)) {
|
||||
const list = refIds.get(m[1]) ?? []
|
||||
list.push(file)
|
||||
refIds.set(m[1], list)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const orphanSpecs = [...specIds.keys()].filter((id) => !refIds.has(id)).sort()
|
||||
const staleRefs = [...refIds.keys()].filter((id) => !specIds.has(id)).sort()
|
||||
|
||||
if (orphanSpecs.length === 0 && staleRefs.length === 0) {
|
||||
console.log(`✔ spec traceability: ${specIds.size} scenarios, all covered by tests`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (orphanSpecs.length) {
|
||||
console.error(`\n✖ ${orphanSpecs.length} spec scenario(s) with no [spec: id] test:`)
|
||||
for (const id of orphanSpecs) console.error(` @${id} (${specIds.get(id).replace(`${ROOT}/`, '')})`)
|
||||
}
|
||||
if (staleRefs.length) {
|
||||
console.error(`\n✖ ${staleRefs.length} [spec: id] breadcrumb(s) with no matching scenario:`)
|
||||
for (const id of staleRefs) console.error(` [spec: ${id}] (${refIds.get(id)[0].replace(`${ROOT}/`, '')})`)
|
||||
}
|
||||
process.exit(1)
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import { processArchiveJob } from '../../usecases/archive-processing'
|
||||
import type { ArchiveJobMessage, ArchiveJobsGateway } from '../../usecases/ports'
|
||||
import { createArchiveTargetFolderRepo } from '../repos/archive-target-folder'
|
||||
import { createBackgroundJobRepo } from '../repos/background-job'
|
||||
import { createMatterRepo } from '../repos/matter'
|
||||
import { createNotificationRepo } from '../repos/notification'
|
||||
import { createQuotaRepo } from '../repos/quota'
|
||||
import { createStorageRepo } from '../repos/storage'
|
||||
import { createStorageUsageRepo } from '../repos/storage-usage'
|
||||
import { createZipPlanRepo } from '../repos/zip'
|
||||
import { S3Service } from './s3'
|
||||
import { createZipGateway } from './zip'
|
||||
|
||||
export const ARCHIVE_QUEUE_BINDING = 'ARCHIVE_QUEUE'
|
||||
|
||||
interface QueueProducer {
|
||||
send(message: ArchiveJobMessage): Promise<void>
|
||||
}
|
||||
|
||||
// When no queue binding is present (Node/dev), jobs run in-process. The queue
|
||||
// drains on the next tick so dispatch resolves before the work starts, matching
|
||||
// the fire-and-forget semantics of a Cloudflare Queue producer.
|
||||
class LocalArchiveQueue {
|
||||
private readonly pending: ArchiveJobMessage[] = []
|
||||
private running = false
|
||||
|
||||
constructor(private readonly run: (message: ArchiveJobMessage) => Promise<void>) {}
|
||||
|
||||
push(message: ArchiveJobMessage): void {
|
||||
this.pending.push(message)
|
||||
if (!this.running) setTimeout(() => void this.drain(), 0)
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.running) return
|
||||
this.running = true
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const next = this.pending.shift()
|
||||
if (!next) return
|
||||
try {
|
||||
await this.run(next)
|
||||
} catch (error) {
|
||||
console.error('[archive-jobs] local worker failed:', error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.running = false
|
||||
if (this.pending.length > 0) setTimeout(() => void this.drain(), 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createArchiveJobsGateway(platform: Platform): ArchiveJobsGateway {
|
||||
const { db } = platform
|
||||
// The archive usecase composes existing ports; assemble exactly the subset it
|
||||
// needs from the platform here (the queue consumer entrypoint), so composition
|
||||
// can keep constructing this gateway with only the platform.
|
||||
const deps = {
|
||||
s3: new S3Service(),
|
||||
storages: createStorageRepo(db),
|
||||
quota: createQuotaRepo(db),
|
||||
storageUsage: createStorageUsageRepo(db),
|
||||
backgroundJobs: createBackgroundJobRepo(db),
|
||||
notifications: createNotificationRepo(db),
|
||||
zip: createZipGateway(),
|
||||
zipPlan: createZipPlanRepo(db),
|
||||
archiveTargetFolders: createArchiveTargetFolderRepo(db),
|
||||
matter: createMatterRepo(db),
|
||||
}
|
||||
|
||||
async function runMessage(message: ArchiveJobMessage): Promise<void> {
|
||||
await processArchiveJob(deps, {
|
||||
orgId: message.orgId,
|
||||
userId: message.userId,
|
||||
request: message.request,
|
||||
jobId: message.jobId,
|
||||
})
|
||||
}
|
||||
|
||||
const localQueue = new LocalArchiveQueue(runMessage)
|
||||
|
||||
return {
|
||||
async dispatch(message) {
|
||||
const queue = platform.getBinding<QueueProducer>(ARCHIVE_QUEUE_BINDING)
|
||||
if (queue) {
|
||||
await queue.send(message)
|
||||
return
|
||||
}
|
||||
localQueue.push(message)
|
||||
},
|
||||
runMessage,
|
||||
}
|
||||
}
|
||||
+68
-63
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as schema from '../db/schema.js'
|
||||
import type { Platform } from '../platform/interface'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { getEmailConfig, getEmailSettings, isEmailConfigured, sendEmail } from './email.js'
|
||||
import { createSystemOptionsRepo } from '../../adapters/repos/system-options.js'
|
||||
import * as schema from '../../db/schema.js'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createEmailGateway } from './email.js'
|
||||
|
||||
const sendMailMock = vi.fn()
|
||||
|
||||
@@ -10,16 +11,39 @@ vi.mock('nodemailer', () => ({
|
||||
createTransport: vi.fn(() => ({ sendMail: sendMailMock })),
|
||||
}))
|
||||
|
||||
describe('getEmailConfig', () => {
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
function gatewayFor(db: TestDb) {
|
||||
return createEmailGateway(createSystemOptionsRepo(db))
|
||||
}
|
||||
|
||||
// Binding-free platform — equivalent to the old bare-Database email source.
|
||||
function bareplatform(db: TestDb): Platform {
|
||||
return {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(_key: string) => undefined as T | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function platformWithBinding(db: TestDb, binding: unknown): Platform {
|
||||
return {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? (binding as T) : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('throws when email_provider is not set', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('Email provider not configured')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Email provider not configured')
|
||||
})
|
||||
|
||||
it('throws when email_from is not set', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(schema.systemOptions).values({ key: 'email_provider', value: 'smtp' })
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('Email sender not configured')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Email sender not configured')
|
||||
})
|
||||
|
||||
it('throws when SMTP host is missing', async () => {
|
||||
@@ -28,7 +52,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_provider', value: 'smtp' },
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('SMTP host and port are required')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
|
||||
})
|
||||
|
||||
it('throws when SMTP port is missing', async () => {
|
||||
@@ -38,7 +62,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
{ key: 'email_smtp_host', value: 'smtp.example.com' },
|
||||
])
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('SMTP host and port are required')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
|
||||
})
|
||||
|
||||
it('returns SMTP config when provider is smtp and all required options are set', async () => {
|
||||
@@ -52,7 +76,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_smtp_pass', value: 'secret' },
|
||||
{ key: 'email_smtp_secure', value: 'true' },
|
||||
])
|
||||
const config = await getEmailConfig(db)
|
||||
const config = await gatewayFor(db).getConfig(bareplatform(db))
|
||||
expect(config.provider).toBe('smtp')
|
||||
expect(config.from).toBe('no-reply@example.com')
|
||||
if (config.provider !== 'smtp') throw new Error('expected smtp')
|
||||
@@ -71,7 +95,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_smtp_host', value: 'smtp.example.com' },
|
||||
{ key: 'email_smtp_port', value: '25' },
|
||||
])
|
||||
const config = await getEmailConfig(db)
|
||||
const config = await gatewayFor(db).getConfig(bareplatform(db))
|
||||
if (config.provider !== 'smtp') throw new Error('expected smtp')
|
||||
expect(config.smtp.secure).toBe(false)
|
||||
expect(config.smtp.user).toBe('')
|
||||
@@ -84,7 +108,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_provider', value: 'http' },
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('HTTP email url and api_key are required')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('HTTP email url and api_key are required')
|
||||
})
|
||||
|
||||
it('throws when HTTP apiKey is missing', async () => {
|
||||
@@ -94,7 +118,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
||||
])
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('HTTP email url and api_key are required')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('HTTP email url and api_key are required')
|
||||
})
|
||||
|
||||
it('returns HTTP config when provider is http and all required options are set', async () => {
|
||||
@@ -105,7 +129,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_http_url', value: 'https://api.mail.example.com/send' },
|
||||
{ key: 'email_http_api_key', value: 'my-api-key' },
|
||||
])
|
||||
const config = await getEmailConfig(db)
|
||||
const config = await gatewayFor(db).getConfig(bareplatform(db))
|
||||
expect(config.provider).toBe('http')
|
||||
expect(config.from).toBe('no-reply@example.com')
|
||||
if (config.provider !== 'http') throw new Error('expected http')
|
||||
@@ -119,7 +143,7 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_provider', value: 'unknown' },
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
await expect(getEmailConfig(db)).rejects.toThrow('Unknown email provider: unknown')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow('Unknown email provider: unknown')
|
||||
})
|
||||
|
||||
it('throws when cloudflare provider is selected without EMAIL binding', async () => {
|
||||
@@ -128,13 +152,10 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_provider', value: 'cloudflare' },
|
||||
{ key: 'email_from', value: 'no-reply@zpan.space' },
|
||||
])
|
||||
const platform = {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(_key: string) => undefined as T | undefined,
|
||||
} satisfies Platform
|
||||
|
||||
await expect(getEmailConfig(platform)).rejects.toThrow('Cloudflare email binding "EMAIL" is not configured')
|
||||
await expect(gatewayFor(db).getConfig(bareplatform(db))).rejects.toThrow(
|
||||
'Cloudflare email binding "EMAIL" is not configured',
|
||||
)
|
||||
})
|
||||
|
||||
it('returns Cloudflare config when provider is cloudflare and binding is present', async () => {
|
||||
@@ -143,20 +164,15 @@ describe('getEmailConfig', () => {
|
||||
{ key: 'email_provider', value: 'cloudflare' },
|
||||
{ key: 'email_from', value: 'no-reply@zpan.space' },
|
||||
])
|
||||
const platform = {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(_key: string) => ({ send: vi.fn() }) as T,
|
||||
} satisfies Platform
|
||||
|
||||
await expect(getEmailConfig(platform)).resolves.toEqual({
|
||||
await expect(gatewayFor(db).getConfig(platformWithBinding(db, { send: vi.fn() }))).resolves.toEqual({
|
||||
provider: 'cloudflare',
|
||||
from: 'no-reply@zpan.space',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmail — SMTP provider', () => {
|
||||
describe('send — SMTP provider', () => {
|
||||
beforeEach(() => {
|
||||
sendMailMock.mockReset()
|
||||
})
|
||||
@@ -172,7 +188,7 @@ describe('sendEmail — SMTP provider', () => {
|
||||
{ key: 'email_smtp_port', value: '587' },
|
||||
])
|
||||
|
||||
await sendEmail(db, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
|
||||
await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
|
||||
expect(sendMailMock).toHaveBeenCalledWith({
|
||||
from: 'no-reply@example.com',
|
||||
to: 'user@example.com',
|
||||
@@ -182,7 +198,7 @@ describe('sendEmail — SMTP provider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmail — HTTP provider', () => {
|
||||
describe('send — HTTP provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -200,7 +216,7 @@ describe('sendEmail — HTTP provider', () => {
|
||||
{ key: 'email_http_api_key', value: 'my-api-key' },
|
||||
])
|
||||
|
||||
await sendEmail(db, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
|
||||
await gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hello', html: '<p>Hi</p>' })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('https://api.mail.example.com/send', {
|
||||
method: 'POST',
|
||||
@@ -234,13 +250,13 @@ describe('sendEmail — HTTP provider', () => {
|
||||
{ key: 'email_http_api_key', value: 'my-api-key' },
|
||||
])
|
||||
|
||||
await expect(sendEmail(db, { to: 'bad@example.com', subject: 'Hi', html: '<p>Hi</p>' })).rejects.toThrow(
|
||||
'HTTP email API error (422): Invalid recipient',
|
||||
)
|
||||
await expect(
|
||||
gatewayFor(db).send(bareplatform(db), { to: 'bad@example.com', subject: 'Hi', html: '<p>Hi</p>' }),
|
||||
).rejects.toThrow('HTTP email API error (422): Invalid recipient')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendEmail — Cloudflare provider', () => {
|
||||
describe('send — Cloudflare provider', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
@@ -253,13 +269,12 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_provider', value: 'cloudflare' },
|
||||
{ key: 'email_from', value: 'no-reply@zpan.space' },
|
||||
])
|
||||
const platform = {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
|
||||
} satisfies Platform
|
||||
|
||||
await sendEmail(platform, { to: 'user@example.com', subject: 'Hello', html: '<p>Hi there</p>' })
|
||||
await gatewayFor(db).send(platformWithBinding(db, { send: sendMock }), {
|
||||
to: 'user@example.com',
|
||||
subject: 'Hello',
|
||||
html: '<p>Hi there</p>',
|
||||
})
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith({
|
||||
to: 'user@example.com',
|
||||
@@ -276,13 +291,8 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_enabled', value: 'true' },
|
||||
{ key: 'email_from', value: 'no-reply@zpan.space' },
|
||||
])
|
||||
const platform = {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: vi.fn() } as T) : undefined),
|
||||
} satisfies Platform
|
||||
|
||||
await expect(isEmailConfigured(platform)).resolves.toBe(false)
|
||||
await expect(gatewayFor(db).isConfigured(platformWithBinding(db, { send: vi.fn() }))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('reports false when config exists but email is disabled', async () => {
|
||||
@@ -295,10 +305,10 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_http_api_key', value: 'my-api-key' },
|
||||
])
|
||||
|
||||
await expect(isEmailConfigured(db)).resolves.toBe(false)
|
||||
await expect(sendEmail(db, { to: 'user@example.com', subject: 'Hi', html: '<p>Hi</p>' })).rejects.toThrow(
|
||||
'Email is disabled',
|
||||
)
|
||||
await expect(gatewayFor(db).isConfigured(bareplatform(db))).resolves.toBe(false)
|
||||
await expect(
|
||||
gatewayFor(db).send(bareplatform(db), { to: 'user@example.com', subject: 'Hi', html: '<p>Hi</p>' }),
|
||||
).rejects.toThrow('Email is disabled')
|
||||
})
|
||||
|
||||
it('returns false when email is enabled but provider is missing', async () => {
|
||||
@@ -308,7 +318,7 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
|
||||
await expect(isEmailConfigured(db)).resolves.toBe(false)
|
||||
await expect(gatewayFor(db).isConfigured(bareplatform(db))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns enabled settings with null config when sender is missing', async () => {
|
||||
@@ -318,13 +328,13 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_provider', value: 'smtp' },
|
||||
])
|
||||
|
||||
await expect(getEmailSettings(db)).resolves.toEqual({
|
||||
await expect(gatewayFor(db).getSettings(bareplatform(db))).resolves.toEqual({
|
||||
enabled: true,
|
||||
config: null,
|
||||
})
|
||||
})
|
||||
|
||||
it('rethrows non-configuration errors from getEmailSettings', async () => {
|
||||
it('rethrows non-configuration errors from getSettings', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(schema.systemOptions).values([
|
||||
{ key: 'email_enabled', value: 'true' },
|
||||
@@ -332,10 +342,10 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
|
||||
await expect(getEmailSettings(db)).rejects.toThrow('SMTP host and port are required')
|
||||
await expect(gatewayFor(db).getSettings(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
|
||||
})
|
||||
|
||||
it('rethrows non-configuration errors from isEmailConfigured', async () => {
|
||||
it('rethrows non-configuration errors from isConfigured', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await db.insert(schema.systemOptions).values([
|
||||
{ key: 'email_enabled', value: 'true' },
|
||||
@@ -343,7 +353,7 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_from', value: 'no-reply@example.com' },
|
||||
])
|
||||
|
||||
await expect(isEmailConfigured(db)).rejects.toThrow('SMTP host and port are required')
|
||||
await expect(gatewayFor(db).isConfigured(bareplatform(db))).rejects.toThrow('SMTP host and port are required')
|
||||
})
|
||||
|
||||
it('prefers explicit text for Cloudflare send()', async () => {
|
||||
@@ -354,13 +364,8 @@ describe('sendEmail — Cloudflare provider', () => {
|
||||
{ key: 'email_provider', value: 'cloudflare' },
|
||||
{ key: 'email_from', value: 'no-reply@zpan.space' },
|
||||
])
|
||||
const platform = {
|
||||
db,
|
||||
getEnv: () => undefined,
|
||||
getBinding: <T = unknown>(key: string) => (key === 'EMAIL' ? ({ send: sendMock } as T) : undefined),
|
||||
} satisfies Platform
|
||||
|
||||
await sendEmail(platform, {
|
||||
await gatewayFor(db).send(platformWithBinding(db, { send: sendMock }), {
|
||||
to: 'user@example.com',
|
||||
subject: 'Hello',
|
||||
html: '<p>Hi there</p>',
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type {
|
||||
EmailConfig,
|
||||
EmailGateway,
|
||||
EmailMessage,
|
||||
EmailSettings,
|
||||
HttpConfig,
|
||||
SmtpConfig,
|
||||
SystemOptionsRepo,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
interface CloudflareEmailBinding {
|
||||
send(message: {
|
||||
to: string | string[]
|
||||
from: string | { email: string; name: string }
|
||||
subject: string
|
||||
html?: string
|
||||
text?: string
|
||||
}): Promise<{ messageId: string }>
|
||||
}
|
||||
|
||||
const CLOUDFLARE_EMAIL_BINDING = 'EMAIL'
|
||||
|
||||
function getCloudflareBinding(platform: Platform): CloudflareEmailBinding | undefined {
|
||||
return platform.getBinding<CloudflareEmailBinding>(CLOUDFLARE_EMAIL_BINDING)
|
||||
}
|
||||
|
||||
function isConfigError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message.includes('Email provider not configured') || error.message.includes('Email sender not configured'))
|
||||
)
|
||||
}
|
||||
|
||||
async function sendViaSmtp(from: string, smtp: SmtpConfig, message: EmailMessage): Promise<void> {
|
||||
// Dynamic import: nodemailer uses Node.js net/tls modules unavailable on CF Workers.
|
||||
// This ensures the module is only loaded when SMTP is actually used (Node.js target).
|
||||
const { createTransport } = await import('nodemailer')
|
||||
const transporter = createTransport({
|
||||
host: smtp.host,
|
||||
port: smtp.port,
|
||||
secure: smtp.secure,
|
||||
auth: smtp.user ? { user: smtp.user, pass: smtp.pass } : undefined,
|
||||
})
|
||||
await transporter.sendMail({
|
||||
from,
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
})
|
||||
}
|
||||
|
||||
async function sendViaHttp(from: string, http: HttpConfig, message: EmailMessage): Promise<void> {
|
||||
const res = await fetch(http.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${http.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
from,
|
||||
to: message.to,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text()
|
||||
throw new Error(`HTTP email API error (${res.status}): ${body}`)
|
||||
}
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
async function sendViaCloudflare(platform: Platform, from: string, message: EmailMessage): Promise<void> {
|
||||
const binding = getCloudflareBinding(platform)
|
||||
if (!binding) throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
|
||||
await binding.send({
|
||||
to: message.to,
|
||||
from,
|
||||
subject: message.subject,
|
||||
html: message.html,
|
||||
text: message.text ?? stripHtml(message.html),
|
||||
})
|
||||
}
|
||||
|
||||
export function createEmailGateway(systemOptions: SystemOptionsRepo): EmailGateway {
|
||||
async function loadOptions(): Promise<Map<string, string>> {
|
||||
const rows = await systemOptions.listByKeyLike('email_%')
|
||||
return new Map(rows.map((r) => [r.key, r.value]))
|
||||
}
|
||||
|
||||
async function getConfig(platform: Platform): Promise<EmailConfig> {
|
||||
const opts = await loadOptions()
|
||||
|
||||
const provider = opts.get('email_provider')
|
||||
const from = opts.get('email_from')
|
||||
if (!provider) {
|
||||
throw new Error('Email provider not configured: set email_provider in system options')
|
||||
}
|
||||
if (provider !== 'smtp' && provider !== 'http' && provider !== 'cloudflare') {
|
||||
throw new Error(`Unknown email provider: ${provider}`)
|
||||
}
|
||||
if (!from) throw new Error('Email sender not configured: set email_from in system options')
|
||||
|
||||
if (provider === 'smtp') {
|
||||
const host = opts.get('email_smtp_host')
|
||||
const port = opts.get('email_smtp_port')
|
||||
if (!host || !port) throw new Error('SMTP host and port are required')
|
||||
return {
|
||||
provider,
|
||||
from,
|
||||
smtp: {
|
||||
host,
|
||||
port: Number(port),
|
||||
user: opts.get('email_smtp_user') ?? '',
|
||||
pass: opts.get('email_smtp_pass') ?? '',
|
||||
secure: opts.get('email_smtp_secure') === 'true',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (provider === 'cloudflare') {
|
||||
if (!getCloudflareBinding(platform)) {
|
||||
throw new Error(`Cloudflare email binding "${CLOUDFLARE_EMAIL_BINDING}" is not configured`)
|
||||
}
|
||||
return { provider, from }
|
||||
}
|
||||
const url = opts.get('email_http_url')
|
||||
const apiKey = opts.get('email_http_api_key')
|
||||
if (!url || !apiKey) throw new Error('HTTP email url and api_key are required')
|
||||
return { provider, from, http: { url, apiKey } }
|
||||
}
|
||||
|
||||
async function isEnabled(): Promise<boolean> {
|
||||
const opts = await loadOptions()
|
||||
return opts.get('email_enabled') === 'true'
|
||||
}
|
||||
|
||||
async function isConfigured(platform: Platform): Promise<boolean> {
|
||||
if (!(await isEnabled())) return false
|
||||
try {
|
||||
await getConfig(platform)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (isConfigError(error)) return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getConfig,
|
||||
isConfigured,
|
||||
|
||||
async getSettings(platform: Platform): Promise<EmailSettings> {
|
||||
const enabled = await isEnabled()
|
||||
try {
|
||||
const config = await getConfig(platform)
|
||||
return { enabled, config }
|
||||
} catch (error) {
|
||||
if (isConfigError(error)) return { enabled, config: null }
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
async send(platform: Platform, message: EmailMessage): Promise<void> {
|
||||
if (!(await isConfigured(platform))) throw new Error('Email is disabled')
|
||||
const config = await getConfig(platform)
|
||||
if (config.provider === 'smtp') return sendViaSmtp(config.from, config.smtp, message)
|
||||
if (config.provider === 'http') return sendViaHttp(config.from, config.http, message)
|
||||
return sendViaCloudflare(platform, config.from, message)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type { S3Gateway, StorageRecord, StorageRepo } from '../../usecases/ports'
|
||||
import { createImageUploadGateway, isImageMime } from './image-upload'
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test stub intentionally opaque
|
||||
type Any = any
|
||||
|
||||
function mockR2Bucket() {
|
||||
return {
|
||||
put: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
}
|
||||
}
|
||||
|
||||
function mockPlatform(opts: { r2?: ReturnType<typeof mockR2Bucket>; publicUrl?: string } = {}): Platform {
|
||||
return {
|
||||
db: {} as Any,
|
||||
getEnv: (key) => (key === 'PUBLIC_IMAGES_URL' ? opts.publicUrl : undefined),
|
||||
getBinding: <T>(key: string) => (key === 'PUBLIC_IMAGES' && opts.r2 ? (opts.r2 as unknown as T) : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
// A StorageRepo whose `select('public')` either returns a storage or throws
|
||||
// (no public storage configured). Only `select` is exercised by the gateway.
|
||||
function mockStorages(opts: { storage?: StorageRecord; selectThrows?: boolean } = {}): StorageRepo {
|
||||
return {
|
||||
select: async () => {
|
||||
if (opts.selectThrows) throw new Error('no storage')
|
||||
return opts.storage as StorageRecord
|
||||
},
|
||||
} as unknown as StorageRepo
|
||||
}
|
||||
|
||||
function mockS3() {
|
||||
return {
|
||||
putObject: vi.fn().mockResolvedValue(16),
|
||||
getPublicUrl: vi.fn().mockReturnValue('https://s3.example/bucket/key'),
|
||||
deleteObject: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as S3Gateway & {
|
||||
putObject: ReturnType<typeof vi.fn>
|
||||
getPublicUrl: ReturnType<typeof vi.fn>
|
||||
deleteObject: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
function makeFile(type: string, bytes = 16): File {
|
||||
return new File([new Uint8Array(bytes)], `f.${type.split('/')[1]}`, { type })
|
||||
}
|
||||
|
||||
describe('isImageMime', () => {
|
||||
it('accepts png/jpeg/webp', () => {
|
||||
expect(isImageMime('image/png')).toBe(true)
|
||||
expect(isImageMime('image/jpeg')).toBe(true)
|
||||
expect(isImageMime('image/webp')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects other mimes', () => {
|
||||
expect(isImageMime('image/gif')).toBe(false)
|
||||
expect(isImageMime('application/pdf')).toBe(false)
|
||||
expect(isImageMime('')).toBe(false)
|
||||
expect(isImageMime(undefined)).toBe(false)
|
||||
expect(isImageMime(42)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadPublicImage — R2 binding path', () => {
|
||||
it('uses R2 binding when PUBLIC_IMAGES + PUBLIC_IMAGES_URL both set', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
|
||||
expect(r2.put).toHaveBeenCalledOnce()
|
||||
expect(r2.put.mock.calls[0]?.[0]).toBe('_system/avatars/u1.png')
|
||||
expect(r2.put.mock.calls[0]?.[2]).toEqual({ httpMetadata: { contentType: 'image/png' } })
|
||||
})
|
||||
|
||||
it('maps mime to correct file extension (jpeg → jpg)', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/jpeg'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toMatch(/\.jpg$/)
|
||||
})
|
||||
|
||||
it('trims a trailing slash in PUBLIC_IMAGES_URL', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev/' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toBe('https://pub-abc.r2.dev/_system/avatars/u1.png')
|
||||
})
|
||||
|
||||
it('rejects invalid mime (gif) before touching R2', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/gif'))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(400)
|
||||
expect(r2.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects file > 2 MiB before touching R2', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png', 3 * 1024 * 1024))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(413)
|
||||
expect(r2.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to S3 path when binding is missing (PUBLIC_IMAGES_URL alone ignored)', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(503)
|
||||
})
|
||||
|
||||
it('falls back to S3 path when PUBLIC_IMAGES_URL is missing (binding alone ignored)', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2 })
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(503)
|
||||
expect(r2.put).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns 503 when neither binding nor public S3 storage is available', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform()
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.status).toBe(503)
|
||||
})
|
||||
|
||||
it('uploads via S3 gateway when a public storage is configured', async () => {
|
||||
const s3 = mockS3()
|
||||
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
|
||||
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
|
||||
const platform = mockPlatform()
|
||||
|
||||
const result = await gw.uploadPublicImage(platform, '_system/avatars', 'u1', makeFile('image/png'))
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.url).toBe('https://s3.example/bucket/key')
|
||||
expect(s3.putObject).toHaveBeenCalledOnce()
|
||||
expect(s3.putObject.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
|
||||
expect(s3.getPublicUrl.mock.calls[0]?.[1]).toBe('_system/avatars/u1.png')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deletePublicImageVariants — R2 binding path', () => {
|
||||
it('deletes all 3 mime variants via R2 binding', async () => {
|
||||
const r2 = mockR2Bucket()
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform({ r2, publicUrl: 'https://pub-abc.r2.dev' })
|
||||
|
||||
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
|
||||
|
||||
expect(r2.delete).toHaveBeenCalledTimes(3)
|
||||
const keys = r2.delete.mock.calls.map((c) => c[0] as string)
|
||||
expect(keys).toContain('_system/avatars/u1.png')
|
||||
expect(keys).toContain('_system/avatars/u1.jpg')
|
||||
expect(keys).toContain('_system/avatars/u1.webp')
|
||||
})
|
||||
|
||||
it('deletes all 3 mime variants via S3 gateway when a public storage is configured', async () => {
|
||||
const s3 = mockS3()
|
||||
const storage = { id: 's1', bucket: 'b', endpoint: 'https://s3.example' } as unknown as StorageRecord
|
||||
const gw = createImageUploadGateway(s3, mockStorages({ storage }))
|
||||
const platform = mockPlatform()
|
||||
|
||||
await gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')
|
||||
|
||||
expect(s3.deleteObject).toHaveBeenCalledTimes(3)
|
||||
const keys = s3.deleteObject.mock.calls.map((c) => c[1] as string)
|
||||
expect(keys).toContain('_system/avatars/u1.png')
|
||||
expect(keys).toContain('_system/avatars/u1.jpg')
|
||||
expect(keys).toContain('_system/avatars/u1.webp')
|
||||
})
|
||||
|
||||
it('is a no-op when no backend is configured', async () => {
|
||||
const gw = createImageUploadGateway(mockS3(), mockStorages({ selectThrows: true }))
|
||||
const platform = mockPlatform()
|
||||
await expect(gw.deletePublicImageVariants(platform, '_system/avatars', 'u1')).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import { mimeToExt } from '../../lib/mime-utils'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type {
|
||||
ImageMime,
|
||||
ImageUpload,
|
||||
ImageUploadResult,
|
||||
S3Gateway,
|
||||
StorageRecord,
|
||||
StorageRepo,
|
||||
} from '../../usecases/ports'
|
||||
import { IMAGE_MIMES, MAX_IMAGE_SIZE } from '../../usecases/ports'
|
||||
|
||||
export function isImageMime(v: unknown): v is ImageMime {
|
||||
return typeof v === 'string' && (IMAGE_MIMES as readonly string[]).includes(v)
|
||||
}
|
||||
|
||||
export function imageKey(prefix: string, id: string, mime: ImageMime): string {
|
||||
return `${prefix}/${id}.${mimeToExt(mime)}`
|
||||
}
|
||||
|
||||
// Minimal R2Bucket interface we actually call — typed locally so we don't
|
||||
// depend on @cloudflare/workers-types on non-CF builds.
|
||||
interface R2BucketLike {
|
||||
put(
|
||||
key: string,
|
||||
value: ArrayBuffer | ArrayBufferView | ReadableStream | Blob,
|
||||
options?: { httpMetadata?: { contentType?: string } },
|
||||
): Promise<unknown>
|
||||
delete(key: string): Promise<void>
|
||||
}
|
||||
|
||||
type Backend =
|
||||
| { kind: 'r2'; bucket: R2BucketLike; publicUrlBase: string }
|
||||
| { kind: 's3'; storage: StorageRecord }
|
||||
| { kind: 'none' }
|
||||
|
||||
export function createImageUploadGateway(s3: S3Gateway, storages: StorageRepo): ImageUpload {
|
||||
// CF deployment with `PUBLIC_IMAGES` binding + `PUBLIC_IMAGES_URL` env var →
|
||||
// writes via R2 binding (zero-auth, zero-egress), reads via R2's public
|
||||
// domain (direct browser fetch, no Worker round-trip per image).
|
||||
// Everything else → falls back to the user-configured `mode='public'` S3
|
||||
// storage in the `storages` table.
|
||||
async function getBackend(platform: Platform): Promise<Backend> {
|
||||
const r2 = platform.getBinding<R2BucketLike>('PUBLIC_IMAGES')
|
||||
const publicUrl = platform.getEnv('PUBLIC_IMAGES_URL')
|
||||
if (r2 && publicUrl) {
|
||||
return { kind: 'r2', bucket: r2, publicUrlBase: publicUrl.replace(/\/$/, '') }
|
||||
}
|
||||
try {
|
||||
const storage = await storages.select('public')
|
||||
return { kind: 's3', storage }
|
||||
} catch {
|
||||
return { kind: 'none' }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async uploadPublicImage(platform, prefix, id, file): Promise<ImageUploadResult> {
|
||||
if (!isImageMime(file.type)) {
|
||||
return { ok: false, status: 400, error: 'Only PNG, JPG, and WebP images are allowed' }
|
||||
}
|
||||
if (file.size > MAX_IMAGE_SIZE) {
|
||||
return { ok: false, status: 413, error: 'File too large. Max 2 MiB.' }
|
||||
}
|
||||
|
||||
const backend = await getBackend(platform)
|
||||
if (backend.kind === 'none') {
|
||||
return { ok: false, status: 503, error: 'No public storage configured' }
|
||||
}
|
||||
|
||||
const key = imageKey(prefix, id, file.type)
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
|
||||
if (backend.kind === 'r2') {
|
||||
await backend.bucket.put(key, bytes, { httpMetadata: { contentType: file.type } })
|
||||
return { ok: true, url: `${backend.publicUrlBase}/${key}` }
|
||||
}
|
||||
|
||||
await s3.putObject(backend.storage, key, bytes, file.type)
|
||||
return { ok: true, url: s3.getPublicUrl(backend.storage, key) }
|
||||
},
|
||||
|
||||
// Best-effort delete of every mime variant of a public image. DB clearing is
|
||||
// the caller's responsibility — this only touches the backend object store.
|
||||
async deletePublicImageVariants(platform, prefix, id): Promise<void> {
|
||||
const backend = await getBackend(platform)
|
||||
if (backend.kind === 'none') return
|
||||
|
||||
if (backend.kind === 'r2') {
|
||||
await Promise.allSettled(IMAGE_MIMES.map((mime) => backend.bucket.delete(imageKey(prefix, id, mime))))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await Promise.allSettled(
|
||||
IMAGE_MIMES.map((mime) => s3.deleteObject(backend.storage, imageKey(prefix, id, mime))),
|
||||
)
|
||||
} catch (err) {
|
||||
console.warn('[image-upload] S3 cleanup skipped:', err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -1,8 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CloudInvalidResponseError, CloudNetworkError, CloudUnboundError } from '../../usecases/ports'
|
||||
import {
|
||||
CloudInvalidResponseError,
|
||||
CloudNetworkError,
|
||||
CloudUnboundError,
|
||||
createBoundCloudClient,
|
||||
createPairing,
|
||||
pollPairing,
|
||||
@@ -1,81 +1,19 @@
|
||||
import type { z } from 'zod'
|
||||
import { type CloudClient, createCloudClient } from 'zpan-cloud-sdk'
|
||||
import {
|
||||
type CloudInstanceInfo,
|
||||
CloudInvalidResponseError,
|
||||
CloudNetworkError,
|
||||
CloudUnboundError,
|
||||
type EntitlementRefreshResponse,
|
||||
type LicensingCloudGateway,
|
||||
type PairingPollResponse,
|
||||
type PairingResponse,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
const CLOUD_REQUEST_TIMEOUT_MS = 10_000
|
||||
const JSON_HEADERS = { 'content-type': 'application/json' }
|
||||
|
||||
export interface PairingResponse {
|
||||
code: string
|
||||
pairingUrl: string
|
||||
expiresAt: string
|
||||
}
|
||||
|
||||
export interface PairingPollResponse {
|
||||
status: 'pending' | 'approved' | 'denied' | 'expired'
|
||||
refreshToken?: string
|
||||
certificate?: string
|
||||
binding?: LicenseBindingInfo
|
||||
account?: LicenseAccountInfo
|
||||
}
|
||||
|
||||
export interface EntitlementRefreshResponse {
|
||||
refreshToken: string
|
||||
certificate: string
|
||||
binding: LicenseBindingInfo
|
||||
account: LicenseAccountInfo
|
||||
nextRefreshAfter?: string
|
||||
}
|
||||
|
||||
export interface LicenseBindingInfo {
|
||||
id: string
|
||||
instanceId: string
|
||||
storeId: string
|
||||
authorizedHosts: string[]
|
||||
}
|
||||
|
||||
export interface LicenseAccountInfo {
|
||||
id: string
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
// The payload ZPan Cloud expects for pairing/refresh. Its `runtime` shape is
|
||||
// fixed by zpan-cloud-sdk and is intentionally decoupled from the richer
|
||||
// InstanceInfo the About page consumes (which has flat runtime + platform).
|
||||
export interface CloudInstanceInfo {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
version: string
|
||||
commit?: string | null
|
||||
runtime?: {
|
||||
provider: 'cloudflare' | 'node'
|
||||
target: 'cloudflare-worker' | 'node/docker'
|
||||
} | null
|
||||
server?: { os?: { platform?: string | null; arch?: string | null; release?: string | null } | null } | null
|
||||
node?: { version?: string | null } | null
|
||||
}
|
||||
|
||||
export class CloudInvalidResponseError extends Error {
|
||||
constructor() {
|
||||
super('Cloud response missing certificate')
|
||||
this.name = 'CloudInvalidResponseError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CloudUnboundError extends Error {
|
||||
constructor() {
|
||||
super('Instance unbound from cloud')
|
||||
this.name = 'CloudUnboundError'
|
||||
}
|
||||
}
|
||||
|
||||
export class CloudNetworkError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : 'Cloud network error')
|
||||
this.name = 'CloudNetworkError'
|
||||
}
|
||||
}
|
||||
|
||||
function cloudApiBaseUrl(baseUrl: string): string {
|
||||
return `${baseUrl.replace(/\/$/, '')}/api`
|
||||
}
|
||||
@@ -227,3 +165,15 @@ function cloudErrorCode(data: unknown) {
|
||||
if (error && typeof error === 'object' && 'code' in error && typeof error.code === 'string') return error.code
|
||||
return null
|
||||
}
|
||||
|
||||
export function createLicensingCloudGateway(): LicensingCloudGateway {
|
||||
return {
|
||||
createPairing,
|
||||
pollPairing,
|
||||
refreshEntitlement,
|
||||
unbindCloudLicense,
|
||||
confirmCloudLicense,
|
||||
createBoundCloudClient,
|
||||
requestCloudJson,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Storage } from '@shared/types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Storage } from '../../shared/types'
|
||||
import { S3Service } from './s3.js'
|
||||
|
||||
const mockSend = vi.fn()
|
||||
@@ -11,27 +11,14 @@ import {
|
||||
UploadPartCommand,
|
||||
} from '@aws-sdk/client-s3'
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { attachmentContentDisposition } from '../../shared/content-disposition'
|
||||
|
||||
/**
|
||||
* The subset of a storage row the S3 client needs. A DB storage row
|
||||
* (`typeof storages.$inferSelect`) structurally satisfies this, so callers pass
|
||||
* rows straight from `getStorage` without casting.
|
||||
*/
|
||||
export interface S3StorageCredentials {
|
||||
bucket: string
|
||||
endpoint: string
|
||||
region: string
|
||||
accessKey: string
|
||||
secretKey: string
|
||||
customHost: string | null
|
||||
}
|
||||
import { attachmentContentDisposition } from '@shared/content-disposition'
|
||||
import type { S3Gateway, S3StorageCredentials } from '../../usecases/ports'
|
||||
|
||||
const DEFAULT_EXPIRES_IN = 3600
|
||||
const MULTIPART_PART_SIZE = 5 * 1024 * 1024
|
||||
const SMALL_STREAM_PUT_BUFFER_SIZE = 256 * 1024
|
||||
|
||||
export class S3Service {
|
||||
export class S3Service implements S3Gateway {
|
||||
createClient(storage: S3StorageCredentials): S3Client {
|
||||
return new S3Client({
|
||||
region: storage.region,
|
||||
@@ -501,3 +488,5 @@ function xmlUnescape(value: string): string {
|
||||
.replace(/</g, '<')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
export type { S3StorageCredentials } from '../../usecases/ports'
|
||||
@@ -1,19 +1,17 @@
|
||||
import { Unzip, UnzipInflate, unzipSync } from 'fflate'
|
||||
import { Unzip, UnzipInflate, unzipSync, Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
|
||||
import type {
|
||||
CompressionSourceDirectory,
|
||||
StreamingZipExtraction,
|
||||
StreamingZipFile,
|
||||
ValidatedZip,
|
||||
ZipDirectoryPlan,
|
||||
ZipGateway,
|
||||
ZipSourceObject,
|
||||
ZipSourceStream,
|
||||
} from '../../usecases/ports'
|
||||
import { ZIP_EXTRACT_LIMITS } from '../../usecases/ports'
|
||||
|
||||
export const ZIP_EXTRACT_LIMITS = {
|
||||
totalOutputBytes: 1024 * 1024 * 1024,
|
||||
singleFileBytes: 1024 * 1024 * 1024,
|
||||
fileCount: 1000,
|
||||
directoryDepth: 10,
|
||||
} as const
|
||||
|
||||
export interface ExtractedZipEntry {
|
||||
path: string
|
||||
name: string
|
||||
parentPath: string
|
||||
bytes: Uint8Array
|
||||
size: number
|
||||
}
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
interface CentralDirectoryEntry {
|
||||
name: string
|
||||
@@ -24,34 +22,78 @@ interface CentralDirectoryEntry {
|
||||
externalAttributes: number
|
||||
}
|
||||
|
||||
export interface ZipDirectoryPlan {
|
||||
folders: string[]
|
||||
totalBytes: number
|
||||
fileCount: number
|
||||
function createZipArchive(objects: ZipSourceObject[], directories: CompressionSourceDirectory[] = []): Uint8Array {
|
||||
const zippable: Zippable = {}
|
||||
for (const directory of directories) zippable[`${directory.archivePath}/`] = new Uint8Array()
|
||||
for (const object of objects) zippable[object.archivePath] = object.bytes
|
||||
return zipSync(zippable, { level: 6 })
|
||||
}
|
||||
|
||||
export interface ValidatedZip {
|
||||
files: ExtractedZipEntry[]
|
||||
folders: string[]
|
||||
totalBytes: number
|
||||
function createZipArchiveStream(
|
||||
sources: ZipSourceStream[],
|
||||
directories: CompressionSourceDirectory[] = [],
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const zip = new Zip()
|
||||
zip.ondata = (error, chunk, final) => {
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
return
|
||||
}
|
||||
if (chunk) controller.enqueue(new Uint8Array(chunk))
|
||||
if (final) controller.close()
|
||||
}
|
||||
|
||||
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
|
||||
zip.terminate()
|
||||
controller.error(error)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export interface StreamingZipFile {
|
||||
path: string
|
||||
name: string
|
||||
parentPath: string
|
||||
stream: ReadableStream<Uint8Array>
|
||||
size: Promise<number>
|
||||
async function streamZipEntries(
|
||||
zip: Zip,
|
||||
sources: ZipSourceStream[],
|
||||
directories: CompressionSourceDirectory[],
|
||||
waitForWrites: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const directory of directories) {
|
||||
const entry = new ZipPassThrough(`${directory.archivePath}/`)
|
||||
zip.add(entry)
|
||||
entry.push(new Uint8Array(), true)
|
||||
await waitForWrites()
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
const entry = new ZipDeflate(source.archivePath, { level: 6 })
|
||||
zip.add(entry)
|
||||
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
|
||||
}
|
||||
|
||||
zip.end()
|
||||
}
|
||||
|
||||
export interface StreamingZipExtraction {
|
||||
folders: string[]
|
||||
totalBytes: number
|
||||
async function pushStreamToZipEntry(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
entry: ZipDeflate,
|
||||
waitForWrites: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
entry.push(new Uint8Array(), true)
|
||||
await waitForWrites()
|
||||
return
|
||||
}
|
||||
entry.push(value, false)
|
||||
await waitForWrites()
|
||||
}
|
||||
}
|
||||
|
||||
const textDecoder = new TextDecoder()
|
||||
|
||||
export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
|
||||
function validateAndExtractZip(data: Uint8Array): ValidatedZip {
|
||||
const entries = readCentralDirectory(data)
|
||||
validateEntries(entries)
|
||||
|
||||
@@ -77,7 +119,7 @@ export function validateAndExtractZip(data: Uint8Array): ValidatedZip {
|
||||
return { files, folders, totalBytes }
|
||||
}
|
||||
|
||||
export async function validateZipDirectory(
|
||||
async function validateZipDirectory(
|
||||
size: number,
|
||||
readRange: (start: number, end: number) => Promise<Uint8Array>,
|
||||
): Promise<ZipDirectoryPlan> {
|
||||
@@ -103,7 +145,7 @@ export async function validateZipDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
export async function streamValidatedZip(
|
||||
async function streamValidatedZip(
|
||||
data: ReadableStream<Uint8Array>,
|
||||
onFile: (file: StreamingZipFile) => Promise<void>,
|
||||
): Promise<StreamingZipExtraction> {
|
||||
@@ -335,3 +377,13 @@ function uint16(data: Uint8Array, offset: number): number {
|
||||
function uint32(data: Uint8Array, offset: number): number {
|
||||
return (data[offset] | (data[offset + 1] << 8) | (data[offset + 2] << 16) | (data[offset + 3] << 24)) >>> 0
|
||||
}
|
||||
|
||||
export function createZipGateway(): ZipGateway {
|
||||
return {
|
||||
createZipArchive,
|
||||
createZipArchiveStream,
|
||||
validateAndExtractZip,
|
||||
validateZipDirectory,
|
||||
streamValidatedZip,
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CfConflictError, CfCustomHostnamesClient, createCfClient } from './cf-custom-hostnames.js'
|
||||
import { CfConflictError } from '../../usecases/ports'
|
||||
import { CfCustomHostnamesClient, createCfClient } from './cf-custom-hostnames.js'
|
||||
|
||||
const TEST_CONFIG = {
|
||||
apiToken: 'test-token',
|
||||
+4
-8
@@ -1,19 +1,17 @@
|
||||
import type { CfHostnameStatus, CfHostnamesProvider } from '../../usecases/ports'
|
||||
import { CfConflictError } from '../../usecases/ports'
|
||||
|
||||
interface CfConfig {
|
||||
apiToken: string
|
||||
zoneId: string
|
||||
cnameTarget: string
|
||||
}
|
||||
|
||||
interface CfHostnameStatus {
|
||||
status: 'pending' | 'active' | 'moved' | 'deleted' | 'blocked'
|
||||
ssl_status: string
|
||||
}
|
||||
|
||||
// CfCustomHostnamesClient is a thin wrapper around the Cloudflare Custom
|
||||
// Hostnames API (CF for SaaS). When env vars are absent (Node self-hosted),
|
||||
// register/delete are no-ops and getStatus always returns 'pending' so
|
||||
// domains never auto-verify without crashing the server.
|
||||
export class CfCustomHostnamesClient {
|
||||
export class CfCustomHostnamesClient implements CfHostnamesProvider {
|
||||
private readonly cfg: CfConfig | null
|
||||
|
||||
constructor(cfg: CfConfig | null) {
|
||||
@@ -79,8 +77,6 @@ export class CfCustomHostnamesClient {
|
||||
}
|
||||
}
|
||||
|
||||
export class CfConflictError extends Error {}
|
||||
|
||||
export function createCfClient(getEnv: (key: string) => string | undefined): CfCustomHostnamesClient {
|
||||
const apiToken = getEnv('CF_API_TOKEN')
|
||||
const zoneId = getEnv('CF_ZONE_ID')
|
||||
@@ -1,12 +1,5 @@
|
||||
import { ZPAN_CHANGELOG_RAW_URL, ZPAN_RELEASES_LATEST_API_URL } from '../../shared/constants'
|
||||
|
||||
export interface ChangelogSource {
|
||||
// Newest published release version (without the leading "v"), or null when the
|
||||
// GitHub API is unreachable/rate-limited.
|
||||
latestVersion: string | null
|
||||
// Raw, product-facing CHANGELOG.md markdown for the drawer.
|
||||
markdown: string
|
||||
}
|
||||
import { ZPAN_CHANGELOG_RAW_URL, ZPAN_RELEASES_LATEST_API_URL } from '@shared/constants'
|
||||
import type { ChangelogProvider, ChangelogSource } from '../../usecases/ports'
|
||||
|
||||
async function fetchLatestReleaseVersion(): Promise<string | null> {
|
||||
// Best-effort: the unauthenticated GitHub API is rate-limited (60/hr/IP), so a
|
||||
@@ -51,3 +44,7 @@ export async function fetchChangelog(
|
||||
cache = { at: now, value }
|
||||
return value
|
||||
}
|
||||
|
||||
export function createChangelogProvider(): ChangelogProvider {
|
||||
return { fetchChangelog }
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { and, count, desc, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { organization, user } from '../../db/auth-schema'
|
||||
import { activityEvents } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ActivityRepo } from '../../usecases/ports'
|
||||
|
||||
export function createActivityRepo(db: Database): ActivityRepo {
|
||||
return {
|
||||
async record(event) {
|
||||
await db.insert(activityEvents).values({
|
||||
id: nanoid(),
|
||||
orgId: event.orgId,
|
||||
userId: event.userId,
|
||||
action: event.action,
|
||||
targetType: event.targetType,
|
||||
targetId: event.targetId ?? null,
|
||||
targetName: event.targetName,
|
||||
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
},
|
||||
|
||||
async list(orgId, opts) {
|
||||
const page = opts.page ?? 1
|
||||
const pageSize = opts.pageSize ?? 20
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const countRows = await db.select({ count: count() }).from(activityEvents).where(eq(activityEvents.orgId, orgId))
|
||||
const total = countRows[0]?.count ?? 0
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: activityEvents.id,
|
||||
orgId: activityEvents.orgId,
|
||||
userId: activityEvents.userId,
|
||||
action: activityEvents.action,
|
||||
targetType: activityEvents.targetType,
|
||||
targetId: activityEvents.targetId,
|
||||
targetName: activityEvents.targetName,
|
||||
metadata: activityEvents.metadata,
|
||||
createdAt: activityEvents.createdAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
})
|
||||
.from(activityEvents)
|
||||
.leftJoin(user, eq(activityEvents.userId, user.id))
|
||||
.where(eq(activityEvents.orgId, orgId))
|
||||
.orderBy(desc(activityEvents.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
action: row.action,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
targetName: row.targetName,
|
||||
metadata: row.metadata,
|
||||
createdAt: row.createdAt,
|
||||
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
|
||||
}))
|
||||
|
||||
return { items, total }
|
||||
},
|
||||
|
||||
async listAdminAudit(opts) {
|
||||
const page = opts.page ?? 1
|
||||
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20))
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const filters = [
|
||||
opts.orgId ? eq(activityEvents.orgId, opts.orgId) : undefined,
|
||||
opts.userId ? eq(activityEvents.userId, opts.userId) : undefined,
|
||||
opts.action ? eq(activityEvents.action, opts.action) : undefined,
|
||||
opts.targetType ? eq(activityEvents.targetType, opts.targetType) : undefined,
|
||||
].filter(Boolean) as Parameters<typeof and>
|
||||
|
||||
const whereClause = filters.length > 0 ? and(...filters) : undefined
|
||||
|
||||
const countRows = await db.select({ count: count() }).from(activityEvents).where(whereClause)
|
||||
const total = countRows[0]?.count ?? 0
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: activityEvents.id,
|
||||
orgId: activityEvents.orgId,
|
||||
userId: activityEvents.userId,
|
||||
action: activityEvents.action,
|
||||
targetType: activityEvents.targetType,
|
||||
targetId: activityEvents.targetId,
|
||||
targetName: activityEvents.targetName,
|
||||
metadata: activityEvents.metadata,
|
||||
createdAt: activityEvents.createdAt,
|
||||
userName: user.name,
|
||||
userImage: user.image,
|
||||
orgName: organization.name,
|
||||
})
|
||||
.from(activityEvents)
|
||||
.leftJoin(user, eq(activityEvents.userId, user.id))
|
||||
.leftJoin(organization, eq(activityEvents.orgId, organization.id))
|
||||
.where(whereClause)
|
||||
.orderBy(desc(activityEvents.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
const items = rows.map((row) => ({
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
action: row.action,
|
||||
targetType: row.targetType,
|
||||
targetId: row.targetId,
|
||||
targetName: row.targetName,
|
||||
metadata: row.metadata,
|
||||
createdAt: row.createdAt,
|
||||
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
|
||||
orgName: row.orgName ?? null,
|
||||
}))
|
||||
|
||||
return { items, total, page, pageSize }
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AnnouncementInput } from '@shared/schemas'
|
||||
import { count, desc, eq, ne } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { announcements } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { AnnouncementRecord, AnnouncementRepo } from '../../usecases/ports'
|
||||
|
||||
type AnnouncementRow = typeof announcements.$inferSelect
|
||||
|
||||
function toRecord(row: AnnouncementRow): AnnouncementRecord {
|
||||
return row as AnnouncementRecord
|
||||
}
|
||||
|
||||
function pageParams(page: number, pageSize: number) {
|
||||
return { limit: pageSize, offset: (page - 1) * pageSize }
|
||||
}
|
||||
|
||||
function publishedAtFor(input: AnnouncementInput, existing?: AnnouncementRow): Date | null {
|
||||
if (input.status === 'archived') return existing?.publishedAt ?? null
|
||||
if (input.status !== 'published') return null
|
||||
return existing?.publishedAt ?? new Date()
|
||||
}
|
||||
|
||||
export function createAnnouncementRepo(db: Database): AnnouncementRepo {
|
||||
async function getRow(id: string): Promise<AnnouncementRow | null> {
|
||||
const rows = await db.select().from(announcements).where(eq(announcements.id, id)).limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
async create(input, createdBy) {
|
||||
const now = new Date()
|
||||
const row: AnnouncementRow = {
|
||||
id: nanoid(),
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
priority: input.priority,
|
||||
publishedAt: publishedAtFor(input),
|
||||
expiresAt: null,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(announcements).values(row)
|
||||
return toRecord(row)
|
||||
},
|
||||
|
||||
async listAdmin(opts) {
|
||||
const { limit, offset } = pageParams(opts.page, opts.pageSize)
|
||||
const where = opts.status ? eq(announcements.status, opts.status) : undefined
|
||||
const [items, totalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(announcements)
|
||||
.where(where)
|
||||
.orderBy(desc(announcements.priority), desc(announcements.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(announcements).where(where),
|
||||
])
|
||||
return { items: items.map(toRecord), total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize }
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
const row = await getRow(id)
|
||||
return row ? toRecord(row) : null
|
||||
},
|
||||
|
||||
async update(id, input) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return null
|
||||
|
||||
await db
|
||||
.update(announcements)
|
||||
.set({
|
||||
title: input.title,
|
||||
body: input.body,
|
||||
status: input.status,
|
||||
priority: input.priority,
|
||||
publishedAt: publishedAtFor(input, existing),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(announcements.id, id))
|
||||
|
||||
const updated = await getRow(id)
|
||||
return updated ? toRecord(updated) : null
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return false
|
||||
await db.delete(announcements).where(eq(announcements.id, id))
|
||||
return true
|
||||
},
|
||||
|
||||
async listUser(opts) {
|
||||
const { limit, offset } = pageParams(opts.page, opts.pageSize)
|
||||
const baseCondition = opts.activeOnly ? eq(announcements.status, 'published') : ne(announcements.status, 'draft')
|
||||
const [items, totalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(announcements)
|
||||
.where(baseCondition)
|
||||
.orderBy(desc(announcements.priority), desc(announcements.publishedAt), desc(announcements.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(announcements).where(baseCondition),
|
||||
])
|
||||
return { items: items.map(toRecord), total: totalRows[0]?.count ?? 0, page: opts.page, pageSize: opts.pageSize }
|
||||
},
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -1,7 +1,10 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
import { ApiKeyRateLimitError, verifyApiKey } from './api-keys'
|
||||
import { authedHeaders, createTestApp } from '../../test/setup.js'
|
||||
import { ApiKeyRateLimitError } from '../../usecases/ports'
|
||||
import { createApiKeyGateway } from './api-keys'
|
||||
|
||||
const apiKeys = createApiKeyGateway()
|
||||
|
||||
type TestApp = Awaited<ReturnType<typeof createTestApp>>
|
||||
|
||||
@@ -91,9 +94,9 @@ describe('API key rate limits', () => {
|
||||
const { orgId, userId } = await getUserAndOrg(db)
|
||||
const apiKey = await createOrgApiKey(auth, 'remote-download', orgId, userId, 2)
|
||||
|
||||
expect(await verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
|
||||
expect(await verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
|
||||
await expect(verifyApiKey(auth, db, apiKey.key, 'remote-download')).rejects.toThrow(ApiKeyRateLimitError)
|
||||
expect(await apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
|
||||
expect(await apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).toMatchObject({ id: apiKey.id })
|
||||
await expect(apiKeys.verifyApiKey(auth, db, apiKey.key, 'remote-download')).rejects.toThrow(ApiKeyRateLimitError)
|
||||
expect(await getApiKeyRow(db, apiKey.id)).toMatchObject({ request_count: 2 })
|
||||
})
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defaultKeyHasher } from '@better-auth/api-key'
|
||||
import { API_KEY_TEMPLATES, type ApiKeyPermissions, ApiKeyTemplate } from '@shared/api-key-templates'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { apikey } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
|
||||
|
||||
type VerifyApiKeyResult = {
|
||||
valid: boolean
|
||||
error: { message: string; code: string; details?: { tryAgainIn?: number } } | null
|
||||
key: VerifiedApiKey | null
|
||||
}
|
||||
|
||||
export function createApiKeyGateway(): ApiKeyGateway {
|
||||
return {
|
||||
async verifyApiKey(auth, db, key, configId) {
|
||||
const resolvedConfigId = await resolveApiKeyConfigId(db, key)
|
||||
if (!resolvedConfigId) return null
|
||||
if (configId && resolvedConfigId !== configId) return null
|
||||
const result = await verify(auth, { configId: resolvedConfigId, key })
|
||||
throwIfRateLimited(result)
|
||||
if (result?.valid && result.key) return result.key
|
||||
return null
|
||||
},
|
||||
|
||||
async verifyApiKeyForPermission(auth, db, key, resource, action, configId) {
|
||||
const resolvedConfigId = await resolveApiKeyConfigId(db, key)
|
||||
if (!resolvedConfigId) return null
|
||||
if (configId && resolvedConfigId !== configId) return null
|
||||
const result = await verify(auth, {
|
||||
configId: resolvedConfigId,
|
||||
key,
|
||||
permissions: { [resource]: [action] },
|
||||
})
|
||||
throwIfRateLimited(result)
|
||||
if (result?.valid && result.key) return result.key
|
||||
return null
|
||||
},
|
||||
|
||||
hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource, action) {
|
||||
return permissions?.[resource]?.includes(action) ?? false
|
||||
},
|
||||
|
||||
isOrgApiKey(configId) {
|
||||
return configId !== ApiKeyTemplate.WEBDAV
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function verify(auth: ApiKeyAuth, body: Record<string, unknown>): Promise<VerifyApiKeyResult | null> {
|
||||
try {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: better-auth plugin API is not fully typed
|
||||
return (await (auth.api as any).verifyApiKey({ body })) as VerifyApiKeyResult
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfRateLimited(result: VerifyApiKeyResult | null) {
|
||||
if (result?.error?.code !== 'RATE_LIMITED') return
|
||||
throw new ApiKeyRateLimitError(result.error.message, result.error.details?.tryAgainIn)
|
||||
}
|
||||
|
||||
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<string | null> {
|
||||
const hashedKey = await defaultKeyHasher(rawKey)
|
||||
const rows = await db.select({ configId: apikey.configId }).from(apikey).where(eq(apikey.key, hashedKey)).limit(1)
|
||||
const configId = rows[0]?.configId
|
||||
if (!configId || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return null
|
||||
return configId
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { DirType } from '../../../shared/constants'
|
||||
import { matters } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ArchiveTargetFolderRepo } from '../../usecases/ports'
|
||||
|
||||
async function requireTargetFolder(db: Database, orgId: string, targetFolder: string): Promise<void> {
|
||||
if (targetFolder === '') return
|
||||
|
||||
const slash = targetFolder.lastIndexOf('/')
|
||||
const parent = slash >= 0 ? targetFolder.slice(0, slash) : ''
|
||||
const name = slash >= 0 ? targetFolder.slice(slash + 1) : targetFolder
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(
|
||||
and(eq(matters.orgId, orgId), eq(matters.parent, parent), eq(matters.name, name), eq(matters.status, 'active')),
|
||||
)
|
||||
.limit(1)
|
||||
const target = rows[0]
|
||||
if (!target) throw new Error('Target folder not found')
|
||||
if (target.dirtype === DirType.FILE) throw new Error('Target folder must be a folder')
|
||||
}
|
||||
|
||||
export function createArchiveTargetFolderRepo(db: Database): ArchiveTargetFolderRepo {
|
||||
return {
|
||||
requireTargetFolder: (orgId, targetFolder) => requireTargetFolder(db, orgId, targetFolder),
|
||||
}
|
||||
}
|
||||
+29
-25
@@ -1,18 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
cancelBackgroundJob,
|
||||
createBackgroundJob,
|
||||
getBackgroundJob,
|
||||
listBackgroundJobs,
|
||||
retryBackgroundJob,
|
||||
updateBackgroundJob,
|
||||
} from './background-jobs'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createBackgroundJobRepo } from './background-job'
|
||||
|
||||
describe('background job service', () => {
|
||||
it('creates, lists, and reads jobs with generic metadata and progress', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const job = await createBackgroundJob(db, {
|
||||
const job = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-service',
|
||||
userId: 'user-service',
|
||||
type: 'remote_download',
|
||||
@@ -22,8 +15,12 @@ describe('background job service', () => {
|
||||
progress: { inputBytes: 1024, currentFilename: 'file.zip' },
|
||||
})
|
||||
|
||||
const listed = await listBackgroundJobs(db, 'org-service', { type: 'remote_download', page: 1, pageSize: 10 })
|
||||
const loaded = await getBackgroundJob(db, 'org-service', job.id)
|
||||
const listed = await createBackgroundJobRepo(db).list('org-service', {
|
||||
type: 'remote_download',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})
|
||||
const loaded = await createBackgroundJobRepo(db).get('org-service', job.id)
|
||||
|
||||
expect(listed).toMatchObject({ total: 1, items: [{ id: job.id }] })
|
||||
expect(loaded).toMatchObject({
|
||||
@@ -38,20 +35,20 @@ describe('background job service', () => {
|
||||
|
||||
it('updates nullable fields and terminal timestamps explicitly', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const job = await createBackgroundJob(db, {
|
||||
const job = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-update',
|
||||
userId: 'user-update',
|
||||
type: 'archive_compress',
|
||||
progress: { currentFilename: 'old.txt' },
|
||||
})
|
||||
await updateBackgroundJob(db, 'org-update', job.id, {
|
||||
await createBackgroundJobRepo(db).update('org-update', job.id, {
|
||||
status: 'failed',
|
||||
errorMessage: 'first_failure',
|
||||
progress: { currentFilename: null },
|
||||
resultMetadata: { attempted: true },
|
||||
})
|
||||
|
||||
const updated = await updateBackgroundJob(db, 'org-update', job.id, {
|
||||
const updated = await createBackgroundJobRepo(db).update('org-update', job.id, {
|
||||
errorMessage: null,
|
||||
resultMetadata: null,
|
||||
})
|
||||
@@ -65,42 +62,49 @@ describe('background job service', () => {
|
||||
|
||||
it('cancels active cancelable jobs and rejects unsupported cancellation', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const cancelable = await createBackgroundJob(db, {
|
||||
const cancelable = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-cancel',
|
||||
userId: 'user-cancel',
|
||||
type: 'archive_extract',
|
||||
})
|
||||
const blocked = await createBackgroundJob(db, {
|
||||
const blocked = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-cancel',
|
||||
userId: 'user-cancel',
|
||||
type: 'archive_extract',
|
||||
cancelable: false,
|
||||
})
|
||||
|
||||
await expect(cancelBackgroundJob(db, 'org-cancel', cancelable.id)).resolves.toMatchObject({ status: 'canceled' })
|
||||
await expect(cancelBackgroundJob(db, 'org-cancel', blocked.id)).rejects.toMatchObject({ code: 'not_cancelable' })
|
||||
await expect(createBackgroundJobRepo(db).cancel('org-cancel', cancelable.id)).resolves.toMatchObject({
|
||||
status: 'canceled',
|
||||
})
|
||||
await expect(createBackgroundJobRepo(db).cancel('org-cancel', blocked.id)).rejects.toMatchObject({
|
||||
code: 'not_cancelable',
|
||||
})
|
||||
})
|
||||
|
||||
it('creates retries only for failed retryable jobs', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const retryable = await createBackgroundJob(db, {
|
||||
const retryable = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-retry',
|
||||
userId: 'user-retry',
|
||||
type: 'archive_extract',
|
||||
retryable: true,
|
||||
})
|
||||
const notRetryable = await createBackgroundJob(db, {
|
||||
const notRetryable = await createBackgroundJobRepo(db).create({
|
||||
orgId: 'org-retry',
|
||||
userId: 'user-retry',
|
||||
type: 'archive_extract',
|
||||
})
|
||||
await updateBackgroundJob(db, 'org-retry', retryable.id, { status: 'failed', errorMessage: 'bad_zip' })
|
||||
await updateBackgroundJob(db, 'org-retry', notRetryable.id, { status: 'failed', errorMessage: 'bad_zip' })
|
||||
await createBackgroundJobRepo(db).update('org-retry', retryable.id, { status: 'failed', errorMessage: 'bad_zip' })
|
||||
await createBackgroundJobRepo(db).update('org-retry', notRetryable.id, {
|
||||
status: 'failed',
|
||||
errorMessage: 'bad_zip',
|
||||
})
|
||||
|
||||
const retry = await retryBackgroundJob(db, 'org-retry', retryable.id)
|
||||
const retry = await createBackgroundJobRepo(db).retry('org-retry', retryable.id)
|
||||
|
||||
expect(retry).toMatchObject({ status: 'queued', retriedFromJobId: retryable.id, errorMessage: null })
|
||||
await expect(retryBackgroundJob(db, 'org-retry', notRetryable.id)).rejects.toMatchObject({
|
||||
await expect(createBackgroundJobRepo(db).retry('org-retry', notRetryable.id)).rejects.toMatchObject({
|
||||
code: 'not_retryable',
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
import type { BackgroundJob, BackgroundJobStatus } from '@shared/types'
|
||||
import { and, count, desc, eq, type SQL } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { backgroundJobs } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
BackgroundJobError,
|
||||
type BackgroundJobMetadata,
|
||||
type BackgroundJobRepo,
|
||||
type ListBackgroundJobsOptions,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
type BackgroundJobRow = typeof backgroundJobs.$inferSelect
|
||||
|
||||
const ACTIVE_STATUSES: BackgroundJobStatus[] = ['queued', 'running']
|
||||
|
||||
function backgroundJobWhere(orgId: string, opts: ListBackgroundJobsOptions): SQL | undefined {
|
||||
const filters = [eq(backgroundJobs.orgId, orgId)]
|
||||
if (opts.status) filters.push(eq(backgroundJobs.status, opts.status))
|
||||
if (opts.type) filters.push(eq(backgroundJobs.type, opts.type))
|
||||
return and(...filters)
|
||||
}
|
||||
|
||||
function finishedAtFor(status: string, current: Date | null, now: Date): Date | null {
|
||||
if (current) return current
|
||||
return ['completed', 'failed', 'canceled'].includes(status) ? now : null
|
||||
}
|
||||
|
||||
function stringifyMetadata(value: BackgroundJobMetadata | null | undefined): string | null {
|
||||
return value == null ? null : JSON.stringify(value)
|
||||
}
|
||||
|
||||
function parseMetadata(value: string | null): BackgroundJobMetadata | null {
|
||||
return value == null ? null : (JSON.parse(value) as BackgroundJobMetadata)
|
||||
}
|
||||
|
||||
function toIso(value: Date | null): string | null {
|
||||
return value?.toISOString() ?? null
|
||||
}
|
||||
|
||||
function toBackgroundJob(row: BackgroundJobRow): BackgroundJob {
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
type: row.type,
|
||||
status: row.status as BackgroundJobStatus,
|
||||
targetFolder: row.targetFolder,
|
||||
targetPath: row.targetPath,
|
||||
metadata: parseMetadata(row.metadata),
|
||||
progress: {
|
||||
inputBytes: row.inputBytes,
|
||||
outputBytes: row.outputBytes,
|
||||
processedBytes: row.processedBytes,
|
||||
fileCount: row.fileCount,
|
||||
currentFilename: row.currentFilename,
|
||||
},
|
||||
errorMessage: row.errorMessage,
|
||||
resultMetadata: parseMetadata(row.resultMetadata),
|
||||
retryable: row.retryable,
|
||||
cancelable: row.cancelable,
|
||||
retriedFromJobId: row.retriedFromJobId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
startedAt: toIso(row.startedAt),
|
||||
finishedAt: toIso(row.finishedAt),
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackgroundJobRepo(db: Database): BackgroundJobRepo {
|
||||
async function getRow(orgId: string, id: string): Promise<BackgroundJobRow | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(backgroundJobs)
|
||||
.where(and(eq(backgroundJobs.id, id), eq(backgroundJobs.orgId, orgId)))
|
||||
.limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
const repo: BackgroundJobRepo = {
|
||||
async create(input) {
|
||||
const now = new Date()
|
||||
const row: typeof backgroundJobs.$inferInsert = {
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
status: 'queued',
|
||||
targetFolder: input.targetFolder ?? null,
|
||||
targetPath: input.targetPath ?? null,
|
||||
metadata: stringifyMetadata(input.metadata),
|
||||
inputBytes: input.progress?.inputBytes ?? 0,
|
||||
outputBytes: input.progress?.outputBytes ?? 0,
|
||||
processedBytes: input.progress?.processedBytes ?? 0,
|
||||
fileCount: input.progress?.fileCount ?? 0,
|
||||
currentFilename: input.progress?.currentFilename ?? null,
|
||||
errorMessage: null,
|
||||
resultMetadata: null,
|
||||
retryable: input.retryable ?? false,
|
||||
cancelable: input.cancelable ?? true,
|
||||
retriedFromJobId: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
}
|
||||
await db.insert(backgroundJobs).values(row)
|
||||
return toBackgroundJob(row as BackgroundJobRow)
|
||||
},
|
||||
|
||||
async list(orgId, opts) {
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
const where = backgroundJobWhere(orgId, opts)
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(backgroundJobs)
|
||||
.where(where)
|
||||
.orderBy(desc(backgroundJobs.createdAt))
|
||||
.limit(opts.pageSize)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(backgroundJobs).where(where),
|
||||
])
|
||||
return { items: rows.map(toBackgroundJob), total: totalRows[0]?.count ?? 0 }
|
||||
},
|
||||
|
||||
async get(orgId, id) {
|
||||
const row = await getRow(orgId, id)
|
||||
if (!row) throw new BackgroundJobError('not_found')
|
||||
return toBackgroundJob(row)
|
||||
},
|
||||
|
||||
async update(orgId, id, input) {
|
||||
const row = await getRow(orgId, id)
|
||||
if (!row) throw new BackgroundJobError('not_found')
|
||||
|
||||
const nextStatus = input.status ?? row.status
|
||||
const now = new Date()
|
||||
const values: Partial<typeof backgroundJobs.$inferInsert> = {
|
||||
status: nextStatus,
|
||||
inputBytes: input.progress?.inputBytes ?? row.inputBytes,
|
||||
outputBytes: input.progress?.outputBytes ?? row.outputBytes,
|
||||
processedBytes: input.progress?.processedBytes ?? row.processedBytes,
|
||||
fileCount: input.progress?.fileCount ?? row.fileCount,
|
||||
currentFilename:
|
||||
input.progress?.currentFilename === undefined ? row.currentFilename : input.progress.currentFilename,
|
||||
errorMessage: input.errorMessage === undefined ? row.errorMessage : input.errorMessage,
|
||||
resultMetadata:
|
||||
input.resultMetadata === undefined ? row.resultMetadata : stringifyMetadata(input.resultMetadata),
|
||||
retryable: input.retryable ?? row.retryable,
|
||||
cancelable: input.cancelable ?? row.cancelable,
|
||||
startedAt: input.startedAt === undefined ? row.startedAt : input.startedAt,
|
||||
finishedAt: input.finishedAt === undefined ? finishedAtFor(nextStatus, row.finishedAt, now) : input.finishedAt,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.update(backgroundJobs).set(values).where(eq(backgroundJobs.id, id))
|
||||
return repo.get(orgId, id)
|
||||
},
|
||||
|
||||
async cancel(orgId, id) {
|
||||
const row = await getRow(orgId, id)
|
||||
if (!row) throw new BackgroundJobError('not_found')
|
||||
if (!ACTIVE_STATUSES.includes(row.status as BackgroundJobStatus) || !row.cancelable) {
|
||||
throw new BackgroundJobError('not_cancelable')
|
||||
}
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(backgroundJobs)
|
||||
.set({ status: 'canceled', updatedAt: now, finishedAt: now })
|
||||
.where(eq(backgroundJobs.id, id))
|
||||
return repo.get(orgId, id)
|
||||
},
|
||||
|
||||
async retry(orgId, id) {
|
||||
const row = await getRow(orgId, id)
|
||||
if (!row) throw new BackgroundJobError('not_found')
|
||||
if (row.status !== 'failed' || !row.retryable) throw new BackgroundJobError('not_retryable')
|
||||
|
||||
const now = new Date()
|
||||
const retry: typeof backgroundJobs.$inferInsert = {
|
||||
id: nanoid(),
|
||||
orgId: row.orgId,
|
||||
userId: row.userId,
|
||||
type: row.type,
|
||||
status: 'queued',
|
||||
targetFolder: row.targetFolder,
|
||||
targetPath: row.targetPath,
|
||||
metadata: row.metadata,
|
||||
inputBytes: row.inputBytes,
|
||||
outputBytes: 0,
|
||||
processedBytes: 0,
|
||||
fileCount: row.fileCount,
|
||||
currentFilename: null,
|
||||
errorMessage: null,
|
||||
resultMetadata: null,
|
||||
retryable: row.retryable,
|
||||
cancelable: row.cancelable,
|
||||
retriedFromJobId: row.id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
}
|
||||
await db.insert(backgroundJobs).values(retry)
|
||||
return toBackgroundJob(retry as BackgroundJobRow)
|
||||
},
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { processCloudOrderQuotaChange } from './cloud-store'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { createCloudStoreRepo } from './cloud-store'
|
||||
|
||||
function processCloudOrderQuotaChange(
|
||||
db: Database,
|
||||
...args: Parameters<ReturnType<typeof createCloudStoreRepo>['processCloudOrderQuotaChange']>
|
||||
) {
|
||||
return createCloudStoreRepo(db).processCloudOrderQuotaChange(...args)
|
||||
}
|
||||
|
||||
function createAsyncDb(
|
||||
quotaRows: Array<{ id: string }> = [{ id: 'quota-1' }],
|
||||
@@ -2,13 +2,14 @@ import type { CloudOrderQuotaChange } from '@shared/schemas'
|
||||
import type { CloudStoreTarget } from '@shared/types'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { member, organization, user } from '../db/auth-schema'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../db/schema'
|
||||
import { loadActiveLicenseBinding } from '../licensing/license-state'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { type AtomicQuery, executeRows, executeWriteTransaction } from './db-transaction'
|
||||
import { member, organization, user } from '../../db/auth-schema'
|
||||
import { activityEvents, orgQuotaEntitlements, orgQuotas, webhookEvents } from '../../db/schema'
|
||||
import { type AtomicQuery, executeRows, executeWriteTransaction } from '../../db/transaction'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { CloudStoreBinding, CloudStoreRepo } from '../../usecases/ports'
|
||||
import { createLicenseBindingRepo } from './license-binding'
|
||||
|
||||
export async function getAccessibleTargets(db: Database, userId: string): Promise<CloudStoreTarget[]> {
|
||||
async function getAccessibleTargets(db: Database, userId: string): Promise<CloudStoreTarget[]> {
|
||||
const rows = await db
|
||||
.select({ orgId: organization.id, name: organization.name, metadata: organization.metadata, role: member.role })
|
||||
.from(member)
|
||||
@@ -19,10 +20,8 @@ export async function getAccessibleTargets(db: Database, userId: string): Promis
|
||||
return rows.map((r) => ({ orgId: r.orgId, name: r.name, type: parseOrgType(r.metadata), role: r.role }))
|
||||
}
|
||||
|
||||
export async function getCloudStoreBinding(
|
||||
db: Database,
|
||||
): Promise<{ boundLicenseId: string; storeId: string; refreshToken: string; instanceId: string }> {
|
||||
const binding = await loadActiveLicenseBinding(db)
|
||||
async function getCloudStoreBinding(db: Database): Promise<CloudStoreBinding> {
|
||||
const binding = await createLicenseBindingRepo(db).loadActiveLicenseBinding()
|
||||
if (!binding?.refreshToken || !binding.cloudStoreId) throw new Error('quota_store_binding_missing')
|
||||
return {
|
||||
boundLicenseId: binding.cloudBindingId,
|
||||
@@ -35,7 +34,7 @@ export async function getCloudStoreBinding(
|
||||
// Cloud-side accounting label for an order. Team purchases are labeled with
|
||||
// the team name (the org is the customer); personal purchases keep the
|
||||
// purchaser's email. Personal orgs are identified by their `personal-` slug.
|
||||
export async function getCustomerLabel(db: Database, userId: string, orgId: string): Promise<string | null> {
|
||||
async function getCustomerLabel(db: Database, userId: string, orgId: string): Promise<string | null> {
|
||||
const orgs = await db
|
||||
.select({ name: organization.name, slug: organization.slug })
|
||||
.from(organization)
|
||||
@@ -48,7 +47,7 @@ export async function getCustomerLabel(db: Database, userId: string, orgId: stri
|
||||
return rows[0]?.email ?? null
|
||||
}
|
||||
|
||||
export async function processCloudOrderQuotaChange(
|
||||
async function processCloudOrderQuotaChange(
|
||||
db: Database,
|
||||
event: CloudOrderQuotaChange,
|
||||
rawPayload: string,
|
||||
@@ -369,3 +368,13 @@ function isUniqueConflict(error: unknown): boolean {
|
||||
const message = error.message.toLowerCase()
|
||||
return message.includes('unique') || message.includes('constraint failed')
|
||||
}
|
||||
|
||||
export function createCloudStoreRepo(db: Database): CloudStoreRepo {
|
||||
return {
|
||||
getAccessibleTargets: (userId) => getAccessibleTargets(db, userId),
|
||||
getCloudStoreBinding: () => getCloudStoreBinding(db),
|
||||
getCustomerLabel: (userId, orgId) => getCustomerLabel(db, userId, orgId),
|
||||
processCloudOrderQuotaChange: (event, rawPayload, payloadHash) =>
|
||||
processCloudOrderQuotaChange(db, event, rawPayload, payloadHash),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { asc, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { cloudTrafficReports } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
CloudTrafficReportRecord,
|
||||
CloudTrafficReportRepo,
|
||||
CloudTrafficReportStatus,
|
||||
InsertCloudTrafficReportInput,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
function toRecord(row: typeof cloudTrafficReports.$inferSelect): CloudTrafficReportRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
period: row.period,
|
||||
source: row.source,
|
||||
sourceId: row.sourceId,
|
||||
eventId: row.eventId,
|
||||
bytes: row.bytes,
|
||||
storageId: row.storageId,
|
||||
unitBytes: row.unitBytes,
|
||||
creditsPerUnit: row.creditsPerUnit,
|
||||
status: row.status as CloudTrafficReportStatus,
|
||||
error: row.error,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createCloudTrafficReportRepo(db: Database): CloudTrafficReportRepo {
|
||||
return {
|
||||
async findByEventId(eventId) {
|
||||
const rows = await db.select().from(cloudTrafficReports).where(eq(cloudTrafficReports.eventId, eventId)).limit(1)
|
||||
return rows[0] ? toRecord(rows[0]) : undefined
|
||||
},
|
||||
|
||||
async insert(input: InsertCloudTrafficReportInput) {
|
||||
await db.insert(cloudTrafficReports).values({
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
period: input.period,
|
||||
source: input.source,
|
||||
sourceId: input.sourceId,
|
||||
eventId: input.eventId,
|
||||
bytes: input.bytes,
|
||||
storageId: input.storageId,
|
||||
unitBytes: input.unitBytes,
|
||||
creditsPerUnit: input.creditsPerUnit,
|
||||
status: input.status,
|
||||
error: null,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
})
|
||||
},
|
||||
|
||||
async updateStatus(eventId, status, error, now) {
|
||||
await db
|
||||
.update(cloudTrafficReports)
|
||||
.set({ status, error, updatedAt: now })
|
||||
.where(eq(cloudTrafficReports.eventId, eventId))
|
||||
},
|
||||
|
||||
async listPending(limit) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(cloudTrafficReports)
|
||||
.where(inArray(cloudTrafficReports.status, ['pending', 'failed']))
|
||||
.orderBy(asc(cloudTrafficReports.createdAt))
|
||||
.limit(limit)
|
||||
return rows.map(toRecord)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { downloadTaskRuntimeSchema } from '@shared/schemas'
|
||||
import type { DownloadTask, DownloadTaskRuntime } from '@shared/types'
|
||||
import { and, asc, count, desc, eq, inArray, like, type SQL, sql } from 'drizzle-orm'
|
||||
import { downloadTasks } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
type CreateDownloadTaskRecordInput,
|
||||
DownloadError,
|
||||
type DownloadTaskRecord,
|
||||
type DownloadTaskRepo,
|
||||
type ListDownloadTasksFilters,
|
||||
type UpdateDownloadTaskFields,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
type DownloadTaskRow = typeof downloadTasks.$inferSelect
|
||||
|
||||
function parseStringArray(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function parseTaskRuntime(value: string | null): DownloadTaskRuntime | null {
|
||||
if (!value) return null
|
||||
return downloadTaskRuntimeSchema.parse(JSON.parse(value))
|
||||
}
|
||||
|
||||
function emptyTaskProgress(): DownloadTask['status']['progress'] {
|
||||
return {
|
||||
download: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
|
||||
upload: { bytes: 0, totalBytes: null, bytesPerSecond: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(row: DownloadTaskRow): DownloadTaskRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
sourceType: row.sourceType,
|
||||
sourceUri: row.sourceUri,
|
||||
displayName: row.displayName,
|
||||
targetFolder: row.targetFolder,
|
||||
category: row.category,
|
||||
tags: row.tags,
|
||||
assignedDownloaderId: row.assignedDownloaderId,
|
||||
status: row.status,
|
||||
attempt: row.attempt,
|
||||
billingAuthorizedBytes: row.billingAuthorizedBytes,
|
||||
billingChargedBytes: row.billingChargedBytes,
|
||||
billingChargedCredits: row.billingChargedCredits,
|
||||
billingStatus: row.billingStatus,
|
||||
errorCode: row.errorCode,
|
||||
errorMessage: row.errorMessage,
|
||||
resultObjectId: row.resultObjectId,
|
||||
runtime: row.runtime,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
assignedAt: row.assignedAt,
|
||||
startedAt: row.startedAt,
|
||||
finishedAt: row.finishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function toDownloadTask(row: DownloadTaskRow): DownloadTask {
|
||||
const runtime = parseTaskRuntime(row.runtime)
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
createdBy: row.createdByUserId,
|
||||
spec: {
|
||||
source: {
|
||||
type: row.sourceType as DownloadTask['spec']['source']['type'],
|
||||
uri: row.sourceUri,
|
||||
},
|
||||
destination: {
|
||||
folder: row.targetFolder,
|
||||
name: row.displayName,
|
||||
},
|
||||
labels: {
|
||||
category: row.category,
|
||||
tags: parseStringArray(row.tags),
|
||||
},
|
||||
},
|
||||
status: {
|
||||
state: row.status as DownloadTask['status']['state'],
|
||||
attempt: row.attempt,
|
||||
assignment: row.assignedDownloaderId
|
||||
? { downloaderId: row.assignedDownloaderId, assignedAt: row.assignedAt?.toISOString() ?? null }
|
||||
: null,
|
||||
progress: runtime?.progress ?? emptyTaskProgress(),
|
||||
billing: {
|
||||
state: row.billingStatus as DownloadTask['status']['billing']['state'],
|
||||
authorizedBytes: row.billingAuthorizedBytes,
|
||||
chargedBytes: row.billingChargedBytes,
|
||||
chargedCredits: row.billingChargedCredits,
|
||||
},
|
||||
output: row.resultObjectId ? { objectId: row.resultObjectId } : null,
|
||||
runtime,
|
||||
error: row.errorMessage ? { code: row.errorCode, message: row.errorMessage } : null,
|
||||
startedAt: row.startedAt?.toISOString() ?? null,
|
||||
finishedAt: row.finishedAt?.toISOString() ?? null,
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function orderBy(sortBy: NonNullable<ListDownloadTasksFilters['sortBy']>, sortDir: 'asc' | 'desc'): SQL {
|
||||
const direction = sortDir === 'asc' ? asc : desc
|
||||
if (sortBy === 'source') return direction(downloadTasks.sourceUri)
|
||||
if (sortBy === 'category') return direction(downloadTasks.category)
|
||||
if (sortBy === 'tags') return direction(downloadTasks.tags)
|
||||
if (sortBy === 'status') return direction(downloadTasks.status)
|
||||
if (sortBy === 'progress') {
|
||||
return direction(sql<number>`
|
||||
case
|
||||
when json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes') is null
|
||||
or json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes') = 0 then 0
|
||||
else (
|
||||
json_extract(${downloadTasks.runtime}, '$.progress.download.bytes') * 1000000 /
|
||||
json_extract(${downloadTasks.runtime}, '$.progress.download.totalBytes')
|
||||
)
|
||||
end
|
||||
`)
|
||||
}
|
||||
if (sortBy === 'eta') {
|
||||
return direction(sql<number>`coalesce(json_extract(${downloadTasks.runtime}, '$.etaSeconds'), 9223372036854775807)`)
|
||||
}
|
||||
return direction(downloadTasks.createdAt)
|
||||
}
|
||||
|
||||
export function createDownloadTaskRepo(db: Database): DownloadTaskRepo {
|
||||
async function findRow(id: string): Promise<DownloadTaskRow | null> {
|
||||
const rows = await db.select().from(downloadTasks).where(eq(downloadTasks.id, id)).limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
async insert(input: CreateDownloadTaskRecordInput) {
|
||||
await db.insert(downloadTasks).values({
|
||||
id: input.id,
|
||||
orgId: input.orgId,
|
||||
createdByUserId: input.createdByUserId,
|
||||
sourceType: input.sourceType,
|
||||
sourceUri: input.sourceUri,
|
||||
displayName: input.displayName,
|
||||
targetFolder: input.targetFolder,
|
||||
category: input.category,
|
||||
tags: JSON.stringify(input.tags),
|
||||
assignedDownloaderId: input.assignedDownloaderId,
|
||||
status: input.status,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
assignedAt: input.assignedAt,
|
||||
})
|
||||
},
|
||||
|
||||
async list(filters: ListDownloadTasksFilters) {
|
||||
const offset = (filters.page - 1) * filters.pageSize
|
||||
const conditions: SQL[] = []
|
||||
if (filters.orgId) conditions.push(eq(downloadTasks.orgId, filters.orgId))
|
||||
if (filters.downloaderId) conditions.push(eq(downloadTasks.assignedDownloaderId, filters.downloaderId))
|
||||
if (filters.status) conditions.push(eq(downloadTasks.status, filters.status))
|
||||
if (filters.category) conditions.push(eq(downloadTasks.category, filters.category))
|
||||
if (filters.tag) conditions.push(like(downloadTasks.tags, `%${JSON.stringify(filters.tag)}%`))
|
||||
const where = conditions.length ? and(...conditions) : undefined
|
||||
const [rows, totalRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(where)
|
||||
.orderBy(orderBy(filters.sortBy ?? 'createdAt', filters.sortDir ?? 'desc'))
|
||||
.limit(filters.pageSize)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(downloadTasks).where(where),
|
||||
])
|
||||
return { items: rows.map(toDownloadTask), total: totalRows[0]?.count ?? 0, rows: rows.map(toRecord) }
|
||||
},
|
||||
|
||||
async get(orgId, id) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return toDownloadTask(rows[0])
|
||||
},
|
||||
|
||||
async getRecord(orgId, id) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(and(eq(downloadTasks.id, id), eq(downloadTasks.orgId, orgId)))
|
||||
.limit(1)
|
||||
if (!rows[0]) throw new DownloadError('not_found')
|
||||
return toRecord(rows[0])
|
||||
},
|
||||
|
||||
async findRecord(id) {
|
||||
const row = await findRow(id)
|
||||
return row ? toRecord(row) : null
|
||||
},
|
||||
|
||||
async setFields(id, fields: UpdateDownloadTaskFields) {
|
||||
await db.update(downloadTasks).set(fields).where(eq(downloadTasks.id, id))
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await db.delete(downloadTasks).where(eq(downloadTasks.id, id))
|
||||
},
|
||||
|
||||
async listQueued(limit) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloadTasks)
|
||||
.where(eq(downloadTasks.status, 'queued'))
|
||||
.orderBy(asc(downloadTasks.createdAt))
|
||||
.limit(limit)
|
||||
return rows.map(toRecord)
|
||||
},
|
||||
|
||||
async requeueAssignedTo(downloaderId, statuses, now) {
|
||||
await db
|
||||
.update(downloadTasks)
|
||||
.set({ status: 'queued', assignedDownloaderId: null, runtime: null, assignedAt: null, updatedAt: now })
|
||||
.where(and(eq(downloadTasks.assignedDownloaderId, downloaderId), inArray(downloadTasks.status, statuses)))
|
||||
},
|
||||
|
||||
async requeueAssignedToMany(downloaderIds, statuses, now) {
|
||||
if (downloaderIds.length === 0) return
|
||||
await db
|
||||
.update(downloadTasks)
|
||||
.set({ status: 'queued', assignedDownloaderId: null, assignedAt: null, runtime: null, updatedAt: now })
|
||||
.where(and(inArray(downloadTasks.assignedDownloaderId, downloaderIds), inArray(downloadTasks.status, statuses)))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { downloaders, downloadTasks } from '../../db/schema'
|
||||
import { constantTimeEqual } from '../../lib/constant-time'
|
||||
import type { Platform } from '../../platform/interface'
|
||||
import type { DownloadTokenClaims, DownloadTokenGateway, TaskUploadTokenClaims } from '../../usecases/ports'
|
||||
import { DOWNLOAD_TOKEN_VERSION } from '../../usecases/ports'
|
||||
|
||||
const downloaderTokenSchema = z.object({
|
||||
v: z.literal(DOWNLOAD_TOKEN_VERSION),
|
||||
typ: z.literal('downloader'),
|
||||
downloaderId: z.string().min(1),
|
||||
jti: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
})
|
||||
|
||||
const taskUploadTokenSchema = z.object({
|
||||
v: z.literal(DOWNLOAD_TOKEN_VERSION),
|
||||
typ: z.literal('download-task-upload'),
|
||||
taskId: z.string().min(1),
|
||||
downloaderId: z.string().min(1),
|
||||
orgId: z.string().min(1),
|
||||
targetFolder: z.string(),
|
||||
createdByUserId: z.string().min(1),
|
||||
scopes: z.array(z.string().min(1)),
|
||||
jti: z.string().min(1),
|
||||
iat: z.number().int(),
|
||||
exp: z.number().int(),
|
||||
})
|
||||
|
||||
export function createDownloadTokenGateway(): DownloadTokenGateway {
|
||||
async function verifyDownloadToken(platform: Platform, token: string): Promise<DownloadTokenClaims | null> {
|
||||
const [payload, signature, extra] = token.split('.')
|
||||
if (!payload || !signature || extra !== undefined) return null
|
||||
const expected = await signPayload(platform, payload)
|
||||
if (!constantTimeEqual(signature, expected)) return null
|
||||
let raw: unknown
|
||||
try {
|
||||
raw = JSON.parse(base64UrlDecode(payload))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const downloader = downloaderTokenSchema.safeParse(raw)
|
||||
if (downloader.success) return downloader.data
|
||||
const task = taskUploadTokenSchema.safeParse(raw)
|
||||
if (!task.success) return null
|
||||
if (task.data.exp <= Math.floor(Date.now() / 1000)) return null
|
||||
return task.data
|
||||
}
|
||||
|
||||
async function hashDownloadToken(platform: Platform, token: string): Promise<string> {
|
||||
const key = await hmacKey(secret(platform))
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`hash:${token}`))
|
||||
return base64UrlEncodeBytes(new Uint8Array(signature))
|
||||
}
|
||||
|
||||
return {
|
||||
async signDownloadToken(platform, claims) {
|
||||
const payload = base64UrlEncode(JSON.stringify(claims))
|
||||
const signature = await signPayload(platform, payload)
|
||||
return `${payload}.${signature}`
|
||||
},
|
||||
|
||||
verifyDownloadToken,
|
||||
hashDownloadToken,
|
||||
|
||||
async resolveDownloaderToken(platform, token) {
|
||||
const claims = await verifyDownloadToken(platform, token)
|
||||
if (!claims || claims.typ !== 'downloader') return null
|
||||
const hash = await hashDownloadToken(platform, token)
|
||||
const rows = await platform.db
|
||||
.select({
|
||||
id: downloaders.id,
|
||||
enabled: downloaders.enabled,
|
||||
tokenHash: downloaders.tokenHash,
|
||||
tokenJti: downloaders.tokenJti,
|
||||
})
|
||||
.from(downloaders)
|
||||
.where(eq(downloaders.id, claims.downloaderId))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
if (!row?.enabled || row.tokenHash !== hash || row.tokenJti !== claims.jti) return null
|
||||
return { downloaderId: row.id }
|
||||
},
|
||||
|
||||
async resolveTaskUploadToken(db, platform, token): Promise<TaskUploadTokenClaims | null> {
|
||||
const claims = await verifyDownloadToken(platform, token)
|
||||
if (!claims || claims.typ !== 'download-task-upload') return null
|
||||
const rows = await db
|
||||
.select({
|
||||
id: downloadTasks.id,
|
||||
assignedDownloaderId: downloadTasks.assignedDownloaderId,
|
||||
status: downloadTasks.status,
|
||||
orgId: downloadTasks.orgId,
|
||||
targetFolder: downloadTasks.targetFolder,
|
||||
createdByUserId: downloadTasks.createdByUserId,
|
||||
})
|
||||
.from(downloadTasks)
|
||||
.where(eq(downloadTasks.id, claims.taskId))
|
||||
.limit(1)
|
||||
const task = rows[0]
|
||||
if (!task) return null
|
||||
if (task.assignedDownloaderId !== claims.downloaderId) return null
|
||||
if (
|
||||
task.orgId !== claims.orgId ||
|
||||
task.targetFolder !== claims.targetFolder ||
|
||||
task.createdByUserId !== claims.createdByUserId
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (!['assigned', 'downloading', 'uploading'].includes(task.status)) return null
|
||||
return claims
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function signPayload(platform: Platform, payload: string): Promise<string> {
|
||||
const key = await hmacKey(secret(platform))
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
|
||||
return base64UrlEncodeBytes(new Uint8Array(signature))
|
||||
}
|
||||
|
||||
async function hmacKey(value: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey('raw', new TextEncoder().encode(value), { name: 'HMAC', hash: 'SHA-256' }, false, [
|
||||
'sign',
|
||||
])
|
||||
}
|
||||
|
||||
function secret(platform: Platform): string {
|
||||
const value = platform.getEnv('BETTER_AUTH_SECRET') ?? platform.getEnv('DOWNLOAD_TOKEN_SECRET')
|
||||
if (!value) throw new Error('download_token_secret_missing')
|
||||
return value
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: string): string {
|
||||
return base64UrlEncodeBytes(new TextEncoder().encode(value))
|
||||
}
|
||||
|
||||
function base64UrlEncodeBytes(bytes: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): string {
|
||||
const padded = value
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
.padEnd(Math.ceil(value.length / 4) * 4, '=')
|
||||
return atob(padded)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Downloader } from '@shared/types'
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt } from 'drizzle-orm'
|
||||
import { downloaders } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
type CreateDownloaderRecordInput,
|
||||
DownloadError,
|
||||
type DownloaderHeartbeatFields,
|
||||
type DownloaderRecord,
|
||||
type DownloaderRepo,
|
||||
type UpdateDownloaderFields,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
type DownloaderRow = typeof downloaders.$inferSelect
|
||||
|
||||
function parseCapabilities(value: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(value)
|
||||
return Array.isArray(parsed) ? parsed.filter((item): item is string => typeof item === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function toRecord(row: DownloaderRow): DownloaderRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
tokenHash: row.tokenHash,
|
||||
tokenJti: row.tokenJti,
|
||||
status: row.status,
|
||||
enabled: row.enabled,
|
||||
version: row.version,
|
||||
hostname: row.hostname,
|
||||
platform: row.platform,
|
||||
arch: row.arch,
|
||||
engine: row.engine,
|
||||
capabilities: parseCapabilities(row.capabilities),
|
||||
maxConcurrentTasks: row.maxConcurrentTasks,
|
||||
currentTasks: row.currentTasks,
|
||||
downloadBps: row.downloadBps,
|
||||
uploadBps: row.uploadBps,
|
||||
freeDiskBytes: row.freeDiskBytes,
|
||||
remoteDownloadCreditBillingEnabled: row.remoteDownloadCreditBillingEnabled,
|
||||
remoteDownloadCreditUnitBytes: row.remoteDownloadCreditUnitBytes,
|
||||
remoteDownloadCreditPerUnit: row.remoteDownloadCreditPerUnit,
|
||||
lastHeartbeatAt: row.lastHeartbeatAt,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function toDownloader(row: DownloaderRow): Downloader {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
status: row.enabled ? (row.status as Downloader['status']) : 'disabled',
|
||||
enabled: row.enabled,
|
||||
version: row.version,
|
||||
hostname: row.hostname,
|
||||
platform: row.platform,
|
||||
arch: row.arch,
|
||||
engine: row.engine as Downloader['engine'],
|
||||
capabilities: parseCapabilities(row.capabilities),
|
||||
maxConcurrentTasks: row.maxConcurrentTasks,
|
||||
currentTasks: row.currentTasks,
|
||||
downloadBps: row.downloadBps,
|
||||
uploadBps: row.uploadBps,
|
||||
freeDiskBytes: row.freeDiskBytes,
|
||||
remoteDownloadCreditBillingEnabled: row.remoteDownloadCreditBillingEnabled,
|
||||
remoteDownloadCreditUnitBytes: row.remoteDownloadCreditUnitBytes,
|
||||
remoteDownloadCreditPerUnit: row.remoteDownloadCreditPerUnit,
|
||||
lastHeartbeatAt: row.lastHeartbeatAt?.toISOString() ?? null,
|
||||
createdBy: row.createdBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT = 1
|
||||
|
||||
export function createDownloaderRepo(db: Database): DownloaderRepo {
|
||||
async function findRow(id: string): Promise<DownloaderRow | null> {
|
||||
const rows = await db.select().from(downloaders).where(eq(downloaders.id, id)).limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
async insert(input: CreateDownloaderRecordInput) {
|
||||
await db.insert(downloaders).values({
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
tokenHash: input.tokenHash,
|
||||
tokenJti: input.tokenJti,
|
||||
status: 'offline',
|
||||
enabled: true,
|
||||
version: input.version,
|
||||
hostname: input.hostname,
|
||||
platform: input.platform,
|
||||
arch: input.arch,
|
||||
engine: input.engine,
|
||||
capabilities: JSON.stringify(input.capabilities),
|
||||
maxConcurrentTasks: input.maxConcurrentTasks,
|
||||
currentTasks: input.currentTasks,
|
||||
downloadBps: input.downloadBps,
|
||||
uploadBps: input.uploadBps,
|
||||
freeDiskBytes: input.freeDiskBytes,
|
||||
remoteDownloadCreditBillingEnabled: false,
|
||||
remoteDownloadCreditUnitBytes: input.remoteDownloadCreditUnitBytes,
|
||||
remoteDownloadCreditPerUnit: DEFAULT_REMOTE_DOWNLOAD_CREDIT_PER_UNIT,
|
||||
lastHeartbeatAt: null,
|
||||
createdBy: input.createdBy,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
})
|
||||
},
|
||||
|
||||
async list() {
|
||||
const rows = await db.select().from(downloaders).orderBy(desc(downloaders.createdAt))
|
||||
return rows.map(toDownloader)
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
const row = await findRow(id)
|
||||
if (!row) throw new DownloadError('not_found')
|
||||
return toDownloader(row)
|
||||
},
|
||||
|
||||
async getRecord(id) {
|
||||
const row = await findRow(id)
|
||||
if (!row) throw new DownloadError('not_found')
|
||||
return toRecord(row)
|
||||
},
|
||||
|
||||
async findRecord(id) {
|
||||
const row = await findRow(id)
|
||||
return row ? toRecord(row) : null
|
||||
},
|
||||
|
||||
async update(id, fields: UpdateDownloaderFields, now) {
|
||||
await db
|
||||
.update(downloaders)
|
||||
.set({
|
||||
...(fields.name !== undefined ? { name: fields.name } : {}),
|
||||
...(fields.enabled !== undefined
|
||||
? { enabled: fields.enabled, status: fields.enabled ? 'offline' : 'disabled' }
|
||||
: {}),
|
||||
...(fields.remoteDownloadCreditBillingEnabled !== undefined
|
||||
? { remoteDownloadCreditBillingEnabled: fields.remoteDownloadCreditBillingEnabled }
|
||||
: {}),
|
||||
...(fields.remoteDownloadCreditUnitBytes !== undefined
|
||||
? { remoteDownloadCreditUnitBytes: fields.remoteDownloadCreditUnitBytes }
|
||||
: {}),
|
||||
...(fields.remoteDownloadCreditPerUnit !== undefined
|
||||
? { remoteDownloadCreditPerUnit: fields.remoteDownloadCreditPerUnit }
|
||||
: {}),
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloaders.id, id))
|
||||
},
|
||||
|
||||
async recordHeartbeat(id, fields: DownloaderHeartbeatFields, online, now) {
|
||||
await db
|
||||
.update(downloaders)
|
||||
.set({
|
||||
status: online ? 'online' : 'disabled',
|
||||
version: fields.version,
|
||||
hostname: fields.hostname,
|
||||
platform: fields.platform,
|
||||
arch: fields.arch,
|
||||
engine: fields.engine,
|
||||
capabilities: JSON.stringify(fields.capabilities),
|
||||
maxConcurrentTasks: fields.maxConcurrentTasks,
|
||||
currentTasks: fields.currentTasks,
|
||||
downloadBps: fields.downloadBps,
|
||||
uploadBps: fields.uploadBps,
|
||||
freeDiskBytes: fields.freeDiskBytes,
|
||||
lastHeartbeatAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(downloaders.id, id))
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
await db.delete(downloaders).where(eq(downloaders.id, id))
|
||||
},
|
||||
|
||||
async listAssignmentCandidates(leaseCutoff) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(downloaders)
|
||||
.where(
|
||||
and(
|
||||
eq(downloaders.enabled, true),
|
||||
eq(downloaders.status, 'online'),
|
||||
gte(downloaders.lastHeartbeatAt, leaseCutoff),
|
||||
gt(downloaders.maxConcurrentTasks, downloaders.currentTasks),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(downloaders.currentTasks), asc(downloaders.downloadBps))
|
||||
return rows.map(toRecord)
|
||||
},
|
||||
|
||||
async listStaleIds(leaseCutoff) {
|
||||
const rows = await db
|
||||
.select({ id: downloaders.id })
|
||||
.from(downloaders)
|
||||
.where(
|
||||
and(
|
||||
eq(downloaders.enabled, true),
|
||||
eq(downloaders.status, 'online'),
|
||||
lt(downloaders.lastHeartbeatAt, leaseCutoff),
|
||||
),
|
||||
)
|
||||
return rows.map((row) => row.id)
|
||||
},
|
||||
|
||||
async markStaleOffline(ids, now) {
|
||||
if (ids.length === 0) return
|
||||
await db
|
||||
.update(downloaders)
|
||||
.set({ status: 'offline', currentTasks: 0, downloadBps: 0, uploadBps: 0, updatedAt: now })
|
||||
.where(inArray(downloaders.id, ids))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { imageHostingConfigs } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ImageHostingConfigRecord, ImageHostingConfigRepo } from '../../usecases/ports'
|
||||
|
||||
export function createImageHostingConfigRepo(db: Database): ImageHostingConfigRepo {
|
||||
return {
|
||||
async getByOrg(orgId) {
|
||||
const rows = await db.select().from(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId)).limit(1)
|
||||
return (rows[0] as ImageHostingConfigRecord | undefined) ?? null
|
||||
},
|
||||
|
||||
async create(input) {
|
||||
const now = new Date()
|
||||
await db.insert(imageHostingConfigs).values({
|
||||
orgId: input.orgId,
|
||||
customDomain: input.customDomain,
|
||||
cfHostnameId: input.cfHostnameId,
|
||||
domainVerifiedAt: null,
|
||||
refererAllowlist: input.refererAllowlist,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
},
|
||||
|
||||
async update(orgId, set) {
|
||||
await db
|
||||
.update(imageHostingConfigs)
|
||||
.set({ ...set, updatedAt: new Date() })
|
||||
.where(eq(imageHostingConfigs.orgId, orgId))
|
||||
},
|
||||
|
||||
async delete(orgId) {
|
||||
await db.delete(imageHostingConfigs).where(eq(imageHostingConfigs.orgId, orgId))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { and, asc, eq, gt, isNotNull, like, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { imageHostingConfigs, imageHostings } from '../../db/schema'
|
||||
import { mimeToExt } from '../../lib/mime-utils'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
CreateImageHostingInput,
|
||||
ImageHostingRecord,
|
||||
ImageHostingRepo,
|
||||
ImageResolution,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
type ImageHostingRow = typeof imageHostings.$inferSelect
|
||||
|
||||
const MAX_COLLISION_RETRIES = 5
|
||||
|
||||
function toRecord(row: ImageHostingRow): ImageHostingRecord {
|
||||
return row as unknown as ImageHostingRecord
|
||||
}
|
||||
|
||||
function parseRefererAllowlist(value: string | null): string[] {
|
||||
return value ? (JSON.parse(value) as string[]) : []
|
||||
}
|
||||
|
||||
function splitStemExt(filename: string): { stem: string; ext: string } {
|
||||
const dot = filename.lastIndexOf('.')
|
||||
if (dot <= 0) return { stem: filename, ext: '' }
|
||||
return { stem: filename.slice(0, dot), ext: filename.slice(dot) }
|
||||
}
|
||||
|
||||
function randomHex4(): string {
|
||||
return Math.floor(Math.random() * 0x10000)
|
||||
.toString(16)
|
||||
.padStart(4, '0')
|
||||
}
|
||||
|
||||
export function createImageHostingRepo(db: Database): ImageHostingRepo {
|
||||
async function resolveUniquePath(orgId: string, requestedPath: string): Promise<string> {
|
||||
const rows = await db
|
||||
.select({ path: imageHostings.path })
|
||||
.from(imageHostings)
|
||||
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, requestedPath)))
|
||||
|
||||
if (rows.length === 0) return requestedPath
|
||||
|
||||
const slashIdx = requestedPath.lastIndexOf('/')
|
||||
const basename = slashIdx >= 0 ? requestedPath.slice(slashIdx + 1) : requestedPath
|
||||
const prefix = slashIdx >= 0 ? requestedPath.slice(0, slashIdx + 1) : ''
|
||||
const { stem, ext } = splitStemExt(basename)
|
||||
|
||||
for (let i = 0; i < MAX_COLLISION_RETRIES; i++) {
|
||||
const candidate = `${prefix}${stem}-${randomHex4()}${ext}`
|
||||
const conflict = await db
|
||||
.select({ path: imageHostings.path })
|
||||
.from(imageHostings)
|
||||
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, candidate)))
|
||||
if (conflict.length === 0) return candidate
|
||||
}
|
||||
|
||||
// Exhausted retries — use nanoid suffix as fallback
|
||||
return `${prefix}${stem}-${nanoid(4)}${ext}`
|
||||
}
|
||||
|
||||
return {
|
||||
async resolveActiveByToken(token): Promise<ImageResolution | null> {
|
||||
const rows = await db.select().from(imageHostings).where(eq(imageHostings.token, token)).limit(1)
|
||||
const row = rows[0]
|
||||
if (!row || row.status !== 'active') return null
|
||||
|
||||
const configRows = await db
|
||||
.select()
|
||||
.from(imageHostingConfigs)
|
||||
.where(eq(imageHostingConfigs.orgId, row.orgId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
image: toRecord(row),
|
||||
refererAllowlist: parseRefererAllowlist(configRows[0]?.refererAllowlist ?? null),
|
||||
}
|
||||
},
|
||||
|
||||
async resolveCustomDomain(host) {
|
||||
const rows = await db
|
||||
.select({ orgId: imageHostingConfigs.orgId })
|
||||
.from(imageHostingConfigs)
|
||||
.where(and(eq(imageHostingConfigs.customDomain, host), isNotNull(imageHostingConfigs.domainVerifiedAt)))
|
||||
.limit(1)
|
||||
return rows[0]?.orgId ?? null
|
||||
},
|
||||
|
||||
async resolveActiveByOrgPath(orgId, path): Promise<ImageResolution | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(imageHostings)
|
||||
.where(and(eq(imageHostings.orgId, orgId), eq(imageHostings.path, path), eq(imageHostings.status, 'active')))
|
||||
.limit(1)
|
||||
if (!rows[0]) return null
|
||||
|
||||
const configRows = await db
|
||||
.select()
|
||||
.from(imageHostingConfigs)
|
||||
.where(eq(imageHostingConfigs.orgId, orgId))
|
||||
.limit(1)
|
||||
if (configRows.length === 0) return null
|
||||
|
||||
return {
|
||||
image: toRecord(rows[0]),
|
||||
refererAllowlist: parseRefererAllowlist(configRows[0].refererAllowlist),
|
||||
}
|
||||
},
|
||||
|
||||
async incrementAccessCount(id) {
|
||||
await db.run(
|
||||
sql`UPDATE image_hostings SET access_count = access_count + 1, last_accessed_at = ${Date.now()} WHERE id = ${id}`,
|
||||
)
|
||||
},
|
||||
|
||||
async create(input: CreateImageHostingInput) {
|
||||
const id = nanoid(12)
|
||||
const token = `ih_${nanoid(10)}`
|
||||
const ext = mimeToExt(input.mime)
|
||||
const storageKey = `ih/${input.orgId}/${id}.${ext}`
|
||||
const now = new Date()
|
||||
|
||||
const resolvedPath = await resolveUniquePath(input.orgId, input.path)
|
||||
|
||||
const row: ImageHostingRow = {
|
||||
id,
|
||||
orgId: input.orgId,
|
||||
token,
|
||||
path: resolvedPath,
|
||||
storageId: input.storageId,
|
||||
storageKey,
|
||||
size: input.size,
|
||||
mime: input.mime,
|
||||
width: null,
|
||||
height: null,
|
||||
status: input.status,
|
||||
accessCount: 0,
|
||||
lastAccessedAt: null,
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
await db.insert(imageHostings).values(row)
|
||||
return toRecord(row)
|
||||
},
|
||||
|
||||
async get(id, orgId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(imageHostings)
|
||||
.where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId)))
|
||||
return rows[0] ? toRecord(rows[0]) : null
|
||||
},
|
||||
|
||||
async list(orgId, opts) {
|
||||
const conditions = [eq(imageHostings.orgId, orgId), eq(imageHostings.status, 'active')]
|
||||
|
||||
if (opts.pathPrefix) {
|
||||
conditions.push(like(imageHostings.path, `${opts.pathPrefix}%`))
|
||||
}
|
||||
|
||||
if (opts.cursor) {
|
||||
// cursor is base64url-encoded ISO timestamp
|
||||
try {
|
||||
const ts = new Date(Buffer.from(opts.cursor, 'base64url').toString())
|
||||
if (!Number.isNaN(ts.getTime())) {
|
||||
conditions.push(gt(imageHostings.createdAt, ts))
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid cursor
|
||||
}
|
||||
}
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(imageHostings)
|
||||
.where(and(...conditions))
|
||||
.orderBy(asc(imageHostings.createdAt))
|
||||
.limit(opts.limit + 1)
|
||||
|
||||
const hasMore = items.length > opts.limit
|
||||
const page = hasMore ? items.slice(0, opts.limit) : items
|
||||
|
||||
const nextCursor =
|
||||
hasMore && page.length > 0
|
||||
? Buffer.from(page[page.length - 1].createdAt.toISOString()).toString('base64url')
|
||||
: null
|
||||
|
||||
return { items: page.map(toRecord), nextCursor }
|
||||
},
|
||||
|
||||
async setActive(id, orgId) {
|
||||
const updated = await db
|
||||
.update(imageHostings)
|
||||
.set({ status: 'active' })
|
||||
.where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId), eq(imageHostings.status, 'draft')))
|
||||
.returning({ id: imageHostings.id })
|
||||
return updated.length > 0
|
||||
},
|
||||
|
||||
async delete(id, orgId) {
|
||||
await db.delete(imageHostings).where(and(eq(imageHostings.id, id), eq(imageHostings.orgId, orgId)))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { systemOptions } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { InstanceRepo } from '../../usecases/ports'
|
||||
|
||||
const INSTANCE_ID_KEY = 'instance_id'
|
||||
|
||||
export function createInstanceRepo(db: Database): InstanceRepo {
|
||||
return {
|
||||
async getOrCreateInstanceId() {
|
||||
const rows = await db
|
||||
.select({ value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
.where(eq(systemOptions.key, INSTANCE_ID_KEY))
|
||||
.limit(1)
|
||||
if (rows[0]?.value) return rows[0].value
|
||||
|
||||
const id = nanoid(21)
|
||||
await db
|
||||
.insert(systemOptions)
|
||||
.values({ key: INSTANCE_ID_KEY, value: id, public: false })
|
||||
.onConflictDoUpdate({ target: systemOptions.key, set: { value: id } })
|
||||
return id
|
||||
},
|
||||
|
||||
async getInstanceDisplayName() {
|
||||
const rows = await db
|
||||
.select({ value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
.where(eq(systemOptions.key, 'site_title'))
|
||||
.limit(1)
|
||||
return rows[0]?.value ?? 'ZPan'
|
||||
},
|
||||
}
|
||||
}
|
||||
+56
-62
@@ -1,36 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
deleteInviteCode,
|
||||
generateInviteCodes,
|
||||
listInviteCodes,
|
||||
redeemInviteCode,
|
||||
validateInviteCode,
|
||||
} from './invite.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createInviteRepo } from './invite.js'
|
||||
|
||||
describe('generateInviteCodes', () => {
|
||||
it('returns the requested number of codes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 5)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 5)
|
||||
expect(codes).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('returns one code when count is 1', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 1)
|
||||
expect(codes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('generates unique codes for each entry', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 10)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 10)
|
||||
const uniqueCodes = new Set(codes.map((c) => c.code))
|
||||
expect(uniqueCodes.size).toBe(10)
|
||||
})
|
||||
|
||||
it('each code has an 8-character uppercase alphanumeric code field', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 3)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 3)
|
||||
for (const code of codes) {
|
||||
expect(code.code).toMatch(/^[0-9A-Z]{8}$/)
|
||||
}
|
||||
@@ -38,7 +32,7 @@ describe('generateInviteCodes', () => {
|
||||
|
||||
it('sets createdBy to the provided admin user id on all codes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-42', 3)
|
||||
const codes = await createInviteRepo(db).generate('admin-42', 3)
|
||||
for (const code of codes) {
|
||||
expect(code.createdBy).toBe('admin-42')
|
||||
}
|
||||
@@ -46,7 +40,7 @@ describe('generateInviteCodes', () => {
|
||||
|
||||
it('sets usedBy and usedAt to null on fresh codes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 2)
|
||||
for (const code of codes) {
|
||||
expect(code.usedBy).toBeNull()
|
||||
expect(code.usedAt).toBeNull()
|
||||
@@ -55,7 +49,7 @@ describe('generateInviteCodes', () => {
|
||||
|
||||
it('sets expiresAt to null when not provided', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 2)
|
||||
for (const code of codes) {
|
||||
expect(code.expiresAt).toBeNull()
|
||||
}
|
||||
@@ -64,7 +58,7 @@ describe('generateInviteCodes', () => {
|
||||
it('propagates expiresAt to all generated codes', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const expiry = new Date(Date.now() + 86400000)
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 3, expiry)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 3, expiry)
|
||||
for (const code of codes) {
|
||||
expect(code.expiresAt).not.toBeNull()
|
||||
}
|
||||
@@ -72,9 +66,9 @@ describe('generateInviteCodes', () => {
|
||||
|
||||
it('persists codes to the database', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const codes = await generateInviteCodes(db, 'admin-1', 2)
|
||||
const codes = await createInviteRepo(db).generate('admin-1', 2)
|
||||
for (const code of codes) {
|
||||
const result = await validateInviteCode(db, code.code)
|
||||
const result = await createInviteRepo(db).validate(code.code)
|
||||
expect(result.valid).toBe(true)
|
||||
}
|
||||
})
|
||||
@@ -83,23 +77,23 @@ describe('generateInviteCodes', () => {
|
||||
describe('validateInviteCode', () => {
|
||||
it('returns valid:true for an unused, unexpired code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
const result = await createInviteRepo(db).validate(row.code)
|
||||
expect(result).toEqual({ valid: true })
|
||||
})
|
||||
|
||||
it('returns valid:false with an error for a nonexistent code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await validateInviteCode(db, 'NOSUCHCD')
|
||||
const result = await createInviteRepo(db).validate('NOSUCHCD')
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns valid:false with an error for a used code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-99')
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).redeem(row.code, 'user-99')
|
||||
const result = await createInviteRepo(db).validate(row.code)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
@@ -107,8 +101,8 @@ describe('validateInviteCode', () => {
|
||||
it('returns valid:false with an error for an expired code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1, pastDate)
|
||||
const result = await createInviteRepo(db).validate(row.code)
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
})
|
||||
@@ -116,8 +110,8 @@ describe('validateInviteCode', () => {
|
||||
it('returns valid:true for a code that has not yet expired', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const futureDate = new Date(Date.now() + 86400000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, futureDate)
|
||||
const result = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1, futureDate)
|
||||
const result = await createInviteRepo(db).validate(row.code)
|
||||
expect(result.valid).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -125,46 +119,46 @@ describe('validateInviteCode', () => {
|
||||
describe('redeemInviteCode', () => {
|
||||
it('returns ok when redeeming a valid unused code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await redeemInviteCode(db, row.code, 'user-55')
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
const result = await createInviteRepo(db).redeem(row.code, 'user-55')
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('marks the code as used so it cannot be validated again', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).redeem(row.code, 'user-55')
|
||||
const check = await createInviteRepo(db).validate(row.code)
|
||||
expect(check.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found for a nonexistent code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await redeemInviteCode(db, 'NOSUCHCD', 'user-55')
|
||||
const result = await createInviteRepo(db).redeem('NOSUCHCD', 'user-55')
|
||||
expect(result).toBe('not_found')
|
||||
})
|
||||
|
||||
it('returns already_used when redeeming a previously redeemed code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const result = await redeemInviteCode(db, row.code, 'user-99')
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).redeem(row.code, 'user-55')
|
||||
const result = await createInviteRepo(db).redeem(row.code, 'user-99')
|
||||
expect(result).toBe('already_used')
|
||||
})
|
||||
|
||||
it('returns expired for an expired code', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1, pastDate)
|
||||
const result = await redeemInviteCode(db, row.code, 'user-55')
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1, pastDate)
|
||||
const result = await createInviteRepo(db).redeem(row.code, 'user-55')
|
||||
expect(result).toBe('expired')
|
||||
})
|
||||
|
||||
it('sets usedAt to a non-null timestamp after redemption', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-55')
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).redeem(row.code, 'user-55')
|
||||
const check = await createInviteRepo(db).validate(row.code)
|
||||
expect(check.error).toContain('used')
|
||||
})
|
||||
})
|
||||
@@ -172,46 +166,46 @@ describe('redeemInviteCode', () => {
|
||||
describe('listInviteCodes', () => {
|
||||
it('returns empty items and total 0 when no codes exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
const result = await createInviteRepo(db).list(1, 20)
|
||||
expect(result).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('returns all codes when fewer than pageSize', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 3)
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
await createInviteRepo(db).generate('admin-1', 3)
|
||||
const result = await createInviteRepo(db).list(1, 20)
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('paginates correctly — page 1 returns first pageSize items', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 5)
|
||||
const result = await listInviteCodes(db, 1, 3)
|
||||
await createInviteRepo(db).generate('admin-1', 5)
|
||||
const result = await createInviteRepo(db).list(1, 3)
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.items).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('paginates correctly — page 2 returns remaining items', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 5)
|
||||
const result = await listInviteCodes(db, 2, 3)
|
||||
await createInviteRepo(db).generate('admin-1', 5)
|
||||
const result = await createInviteRepo(db).list(2, 3)
|
||||
expect(result.total).toBe(5)
|
||||
expect(result.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('returns empty items on a page beyond total count', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 2)
|
||||
const result = await listInviteCodes(db, 5, 20)
|
||||
await createInviteRepo(db).generate('admin-1', 2)
|
||||
const result = await createInviteRepo(db).list(5, 20)
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('orders results by createdAt descending', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await generateInviteCodes(db, 'admin-1', 3)
|
||||
const result = await listInviteCodes(db, 1, 20)
|
||||
await createInviteRepo(db).generate('admin-1', 3)
|
||||
const result = await createInviteRepo(db).list(1, 20)
|
||||
const timestamps = result.items.map((item) => item.createdAt.getTime())
|
||||
const sorted = [...timestamps].sort((a, b) => b - a)
|
||||
expect(timestamps).toEqual(sorted)
|
||||
@@ -221,30 +215,30 @@ describe('listInviteCodes', () => {
|
||||
describe('deleteInviteCode', () => {
|
||||
it('returns ok and removes the code when it exists and is unused', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
const result = await deleteInviteCode(db, row.id)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
const result = await createInviteRepo(db).delete(row.id)
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('removes the code from the database after deletion', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await deleteInviteCode(db, row.id)
|
||||
const check = await validateInviteCode(db, row.code)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).delete(row.id)
|
||||
const check = await createInviteRepo(db).validate(row.code)
|
||||
expect(check.valid).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found for a nonexistent code id', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await deleteInviteCode(db, 'NOSUCHID')
|
||||
const result = await createInviteRepo(db).delete('NOSUCHID')
|
||||
expect(result).toBe('not_found')
|
||||
})
|
||||
|
||||
it('returns already_used for a code that has been redeemed', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const [row] = await generateInviteCodes(db, 'admin-1', 1)
|
||||
await redeemInviteCode(db, row.code, 'user-99')
|
||||
const result = await deleteInviteCode(db, row.id)
|
||||
const [row] = await createInviteRepo(db).generate('admin-1', 1)
|
||||
await createInviteRepo(db).redeem(row.code, 'user-99')
|
||||
const result = await createInviteRepo(db).delete(row.id)
|
||||
expect(result).toBe('already_used')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { and, count, desc, eq, gt, isNull, or } from 'drizzle-orm'
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
import { inviteCodes } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { InviteCodeRecord, InviteRepo } from '../../usecases/ports'
|
||||
|
||||
const generateCode = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8)
|
||||
|
||||
export function createInviteRepo(db: Database): InviteRepo {
|
||||
return {
|
||||
async generate(adminUserId, quantity, expiresAt) {
|
||||
const now = new Date()
|
||||
const rows: InviteCodeRecord[] = Array.from({ length: quantity }, () => ({
|
||||
id: nanoid(),
|
||||
code: generateCode(),
|
||||
createdBy: adminUserId,
|
||||
usedBy: null,
|
||||
usedAt: null,
|
||||
expiresAt: expiresAt ?? null,
|
||||
createdAt: now,
|
||||
}))
|
||||
await db.insert(inviteCodes).values(rows)
|
||||
return rows
|
||||
},
|
||||
|
||||
async validate(code) {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
|
||||
const row = rows[0]
|
||||
if (!row) return { valid: false, error: 'Invalid invite code' }
|
||||
if (row.usedBy) return { valid: false, error: 'Invite code already used' }
|
||||
if (row.expiresAt && row.expiresAt < new Date()) return { valid: false, error: 'Invite code expired' }
|
||||
return { valid: true }
|
||||
},
|
||||
|
||||
async redeem(code, userId) {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.code, code))
|
||||
const row = rows[0]
|
||||
if (!row) return 'not_found'
|
||||
if (row.usedBy) return 'already_used'
|
||||
if (row.expiresAt && row.expiresAt < new Date()) return 'expired'
|
||||
|
||||
const updated = await db
|
||||
.update(inviteCodes)
|
||||
.set({ usedBy: userId, usedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(inviteCodes.code, code),
|
||||
isNull(inviteCodes.usedBy),
|
||||
or(isNull(inviteCodes.expiresAt), gt(inviteCodes.expiresAt, new Date())),
|
||||
),
|
||||
)
|
||||
.returning({ id: inviteCodes.id })
|
||||
|
||||
return updated.length > 0 ? 'ok' : 'already_used'
|
||||
},
|
||||
|
||||
async list(page, pageSize) {
|
||||
const [totalResult, items] = await Promise.all([
|
||||
db.select({ count: count() }).from(inviteCodes),
|
||||
db
|
||||
.select()
|
||||
.from(inviteCodes)
|
||||
.orderBy(desc(inviteCodes.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset((page - 1) * pageSize),
|
||||
])
|
||||
return { items, total: totalResult[0]?.count ?? 0 }
|
||||
},
|
||||
|
||||
async delete(codeId) {
|
||||
const rows = await db.select().from(inviteCodes).where(eq(inviteCodes.id, codeId))
|
||||
const row = rows[0]
|
||||
if (!row) return 'not_found'
|
||||
if (row.usedBy) return 'already_used'
|
||||
await db.delete(inviteCodes).where(eq(inviteCodes.id, codeId))
|
||||
return 'ok'
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,22 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { licenseBindings } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { executeWriteTransaction } from '../services/db-transaction'
|
||||
import { licenseBindings } from '../../db/schema'
|
||||
import { executeWriteTransaction } from '../../db/transaction'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
CreateLicenseBindingInput,
|
||||
LicenseBindingRepo,
|
||||
LicenseBindingStatus,
|
||||
LicenseState,
|
||||
UpdateLicenseBindingInput,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
export type LicenseBindingStatus = 'active' | 'disconnected' | 'revoked'
|
||||
|
||||
export interface LicenseState {
|
||||
id: string
|
||||
cloudBindingId: string
|
||||
cloudStoreId: string | null
|
||||
instanceId: string
|
||||
cloudAccountId: string
|
||||
cloudAccountEmail: string | null
|
||||
status: LicenseBindingStatus
|
||||
refreshToken: string | null
|
||||
cachedCert: string | null
|
||||
cachedExpiresAt: number | null
|
||||
boundAt: number
|
||||
disconnectedAt: number | null
|
||||
lastRefreshAt: number | null
|
||||
lastRefreshError: string | null
|
||||
}
|
||||
|
||||
export async function loadLicenseState(db: Database): Promise<LicenseState> {
|
||||
async function loadLicenseState(db: Database): Promise<LicenseState> {
|
||||
const row = await loadActiveLicenseBinding(db)
|
||||
return row ?? emptyLicenseState()
|
||||
}
|
||||
|
||||
export async function loadActiveLicenseBinding(db: Database): Promise<LicenseState | null> {
|
||||
async function loadActiveLicenseBinding(db: Database): Promise<LicenseState | null> {
|
||||
const rows = await db.select().from(licenseBindings).where(eq(licenseBindings.status, 'active')).limit(1)
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
@@ -51,20 +39,7 @@ export async function loadActiveLicenseBinding(db: Database): Promise<LicenseSta
|
||||
}
|
||||
}
|
||||
|
||||
export async function createLicenseBinding(
|
||||
db: Database,
|
||||
input: {
|
||||
cloudBindingId: string
|
||||
cloudStoreId?: string | null
|
||||
instanceId: string
|
||||
cloudAccountId: string
|
||||
cloudAccountEmail?: string | null
|
||||
refreshToken: string
|
||||
cachedCert: string
|
||||
cachedExpiresAt: number
|
||||
lastRefreshAt: number
|
||||
},
|
||||
): Promise<void> {
|
||||
async function createLicenseBinding(db: Database, input: CreateLicenseBindingInput): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await executeWriteTransaction(db, [
|
||||
db
|
||||
@@ -97,18 +72,7 @@ export async function createLicenseBinding(
|
||||
])
|
||||
}
|
||||
|
||||
export async function updateLicenseBindingAfterRefresh(
|
||||
db: Database,
|
||||
input: {
|
||||
id: string
|
||||
refreshToken: string
|
||||
cloudStoreId?: string | null
|
||||
cachedCert: string
|
||||
cachedExpiresAt: number
|
||||
cloudAccountEmail?: string | null
|
||||
lastRefreshAt: number
|
||||
},
|
||||
): Promise<void> {
|
||||
async function updateLicenseBindingAfterRefresh(db: Database, input: UpdateLicenseBindingInput): Promise<void> {
|
||||
await db
|
||||
.update(licenseBindings)
|
||||
.set({
|
||||
@@ -124,14 +88,14 @@ export async function updateLicenseBindingAfterRefresh(
|
||||
.where(eq(licenseBindings.id, input.id))
|
||||
}
|
||||
|
||||
export async function setLicenseRefreshError(db: Database, id: string, error: string): Promise<void> {
|
||||
async function setLicenseRefreshError(db: Database, id: string, error: string): Promise<void> {
|
||||
await db
|
||||
.update(licenseBindings)
|
||||
.set({ lastRefreshError: error, updatedAt: Math.floor(Date.now() / 1000) })
|
||||
.where(eq(licenseBindings.id, id))
|
||||
}
|
||||
|
||||
export async function clearLicenseBinding(db: Database, status: LicenseBindingStatus = 'disconnected'): Promise<void> {
|
||||
async function clearLicenseBinding(db: Database, status: LicenseBindingStatus = 'disconnected'): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await db
|
||||
.update(licenseBindings)
|
||||
@@ -164,3 +128,14 @@ function emptyLicenseState(): LicenseState {
|
||||
lastRefreshError: null,
|
||||
}
|
||||
}
|
||||
|
||||
export function createLicenseBindingRepo(db: Database): LicenseBindingRepo {
|
||||
return {
|
||||
loadLicenseState: () => loadLicenseState(db),
|
||||
loadActiveLicenseBinding: () => loadActiveLicenseBinding(db),
|
||||
createLicenseBinding: (input) => createLicenseBinding(db, input),
|
||||
updateLicenseBindingAfterRefresh: (input) => updateLicenseBindingAfterRefresh(db, input),
|
||||
setLicenseRefreshError: (id, error) => setLicenseRefreshError(db, id, error),
|
||||
clearLicenseBinding: (status) => clearLicenseBinding(db, status),
|
||||
}
|
||||
}
|
||||
+47
-4
@@ -6,13 +6,56 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ObjectStatus } from '../../shared/constants'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { cancelDraftMatter, confirmUpload, copyMatter, createMatter, restoreMatter, updateMatter } from './matter.js'
|
||||
import { NameConflictError } from './matter-name-conflict.js'
|
||||
import { ObjectStatus } from '../../../shared/constants'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../../usecases/matter'
|
||||
import type {
|
||||
ConflictStrategy,
|
||||
CopyMatterOptions,
|
||||
CreateMatterInput,
|
||||
Matter,
|
||||
UpdateMatterInput,
|
||||
} from '../../usecases/ports'
|
||||
import { NameConflictError } from '../../usecases/ports'
|
||||
import { createActivityRepo } from './activity.js'
|
||||
import { createMatterRepo } from './matter.js'
|
||||
import { createQuotaRepo } from './quota.js'
|
||||
import { createStorageUsageRepo } from './storage-usage.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
// Thin adapters preserving the former matter service signatures so these
|
||||
// behavioral tests exercise the migrated MatterRepo + confirmUpload usecase
|
||||
// unchanged.
|
||||
function createMatter(db: TestDb, input: CreateMatterInput): Promise<Matter> {
|
||||
return createMatterRepo(db).create(input)
|
||||
}
|
||||
function updateMatter(db: TestDb, id: string, orgId: string, input: UpdateMatterInput, userId?: string) {
|
||||
return createMatterRepo(db).update(id, orgId, input, userId)
|
||||
}
|
||||
function copyMatter(db: TestDb, source: Matter, targetParent: string, newObject: string, opts?: CopyMatterOptions) {
|
||||
return createMatterRepo(db).copy(source, targetParent, newObject, opts)
|
||||
}
|
||||
function restoreMatter(db: TestDb, orgId: string, id: string, userId?: string, onConflict?: ConflictStrategy) {
|
||||
return createMatterRepo(db).restore(orgId, id, userId, onConflict)
|
||||
}
|
||||
function cancelDraftMatter(db: TestDb, id: string, orgId: string, userId?: string) {
|
||||
return createMatterRepo(db).cancelDraft(id, orgId, userId)
|
||||
}
|
||||
function confirmUpload(db: TestDb, id: string, orgId: string, opts: ConfirmUploadOptions = {}) {
|
||||
return confirmUploadUsecase(
|
||||
{
|
||||
matter: createMatterRepo(db),
|
||||
quota: createQuotaRepo(db),
|
||||
storageUsage: createStorageUsageRepo(db),
|
||||
activity: createActivityRepo(db),
|
||||
},
|
||||
id,
|
||||
orgId,
|
||||
opts,
|
||||
)
|
||||
}
|
||||
|
||||
const STORAGE_ID = 'st-conflict'
|
||||
|
||||
async function insertStorage(db: TestDb) {
|
||||
+34
-9
@@ -1,18 +1,43 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DirType, ObjectStatus } from '../../shared/constants'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
applyConflictResolution,
|
||||
commitConflictPlan,
|
||||
findActiveConflict,
|
||||
NameConflictError,
|
||||
planConflictResolution,
|
||||
} from './matter-name-conflict.js'
|
||||
import { DirType, ObjectStatus } from '../../../shared/constants'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import type { ConflictPlan, ConflictResolveOptions, ConflictStrategy } from '../../usecases/ports'
|
||||
import { NameConflictError } from '../../usecases/ports'
|
||||
import { createMatterRepo } from './matter.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
// Thin adapters preserving the former matter-name-conflict service signature so
|
||||
// these behavioral tests exercise the migrated MatterRepo unchanged.
|
||||
function findActiveConflict(db: TestDb, orgId: string, parent: string, name: string, excludeId?: string) {
|
||||
return createMatterRepo(db).findActiveConflict(orgId, parent, name, excludeId)
|
||||
}
|
||||
function applyConflictResolution(
|
||||
db: TestDb,
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
strategy: ConflictStrategy,
|
||||
options?: ConflictResolveOptions,
|
||||
) {
|
||||
return createMatterRepo(db).applyConflictResolution(orgId, parent, name, strategy, options)
|
||||
}
|
||||
function planConflictResolution(
|
||||
db: TestDb,
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
strategy: ConflictStrategy,
|
||||
options?: ConflictResolveOptions,
|
||||
) {
|
||||
return createMatterRepo(db).planConflictResolution(orgId, parent, name, strategy, options)
|
||||
}
|
||||
function commitConflictPlan(db: TestDb, orgId: string, plan: ConflictPlan, userId?: string) {
|
||||
return createMatterRepo(db).commitConflictPlan(orgId, plan, userId)
|
||||
}
|
||||
|
||||
async function insertStorage(db: TestDb, id = 'st-1') {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
+87
-24
@@ -1,16 +1,42 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { getEffectiveQuota, hasQuotaForBytes } from './effective-quota.js'
|
||||
import { confirmUpload, listTrashedRoots, updateMatter } from './matter.js'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { type ConfirmUploadOptions, confirmUpload as confirmUploadUsecase } from '../../usecases/matter'
|
||||
import type { UpdateMatterInput } from '../../usecases/ports'
|
||||
import {
|
||||
reconcileStorageUsage,
|
||||
reserveStorageUsage,
|
||||
StorageQuotaExceededError,
|
||||
withStorageUsageReservation,
|
||||
} from './storage-usage.js'
|
||||
} from '../../usecases/storage-usage.js'
|
||||
import { createActivityRepo } from './activity.js'
|
||||
import { createMatterRepo } from './matter.js'
|
||||
import { createQuotaRepo } from './quota.js'
|
||||
import { createStorageUsageRepo } from './storage-usage.js'
|
||||
|
||||
type TestDbForRepo = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
// Thin adapters preserving the former matter service signatures.
|
||||
function confirmUpload(db: TestDbForRepo, id: string, orgId: string, opts: ConfirmUploadOptions = {}) {
|
||||
return confirmUploadUsecase(
|
||||
{
|
||||
matter: createMatterRepo(db),
|
||||
quota: createQuotaRepo(db),
|
||||
storageUsage: createStorageUsageRepo(db),
|
||||
activity: createActivityRepo(db),
|
||||
},
|
||||
id,
|
||||
orgId,
|
||||
opts,
|
||||
)
|
||||
}
|
||||
function listTrashedRoots(db: TestDbForRepo, orgId: string) {
|
||||
return createMatterRepo(db).listTrashedRoots(orgId)
|
||||
}
|
||||
function updateMatter(db: TestDbForRepo, id: string, orgId: string, input: UpdateMatterInput, userId?: string) {
|
||||
return createMatterRepo(db).update(id, orgId, input, userId)
|
||||
}
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
@@ -86,7 +112,10 @@ describe('reserveStorageUsage', () => {
|
||||
const orgId = nanoid()
|
||||
const storageId = await insertStorage(db, { id: 'st-ul', used: 0 })
|
||||
|
||||
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
|
||||
const result = await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 500 },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ orgId, storageId, bytes: 500 })
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
|
||||
@@ -99,7 +128,10 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-q0', used: 100 })
|
||||
await insertOrgQuota(db, orgId, 0, 5000)
|
||||
|
||||
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 999999 })
|
||||
const result = await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 999999 },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ orgId, storageId, bytes: 999999 })
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
|
||||
@@ -111,8 +143,8 @@ describe('reserveStorageUsage', () => {
|
||||
const orgId = nanoid()
|
||||
await insertOrgQuota(db, orgId, 0, 5000)
|
||||
|
||||
await expect(hasQuotaForBytes(db, orgId, 10_000_000)).resolves.toBe(true)
|
||||
await expect(getEffectiveQuota(db, orgId)).resolves.toMatchObject({ baseQuota: 0, quota: 0 })
|
||||
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 10_000_000)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId)).resolves.toMatchObject({ baseQuota: 0, quota: 0 })
|
||||
})
|
||||
|
||||
it('reserves when used + bytes is within quota', async () => {
|
||||
@@ -121,7 +153,10 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-in', used: 0 })
|
||||
await insertOrgQuota(db, orgId, 1000, 400)
|
||||
|
||||
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
|
||||
const result = await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 500 },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ orgId, storageId, bytes: 500 })
|
||||
})
|
||||
@@ -132,7 +167,10 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-exact', used: 0 })
|
||||
await insertOrgQuota(db, orgId, 1000, 500)
|
||||
|
||||
const result = await reserveStorageUsage(db, { orgId, storageId, bytes: 500 })
|
||||
const result = await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 500 },
|
||||
)
|
||||
|
||||
expect(result).toEqual({ orgId, storageId, bytes: 500 })
|
||||
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
@@ -145,7 +183,12 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-over', used: 50 })
|
||||
await insertOrgQuota(db, orgId, 1000, 800)
|
||||
|
||||
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 201 })).rejects.toThrow(StorageQuotaExceededError)
|
||||
await expect(
|
||||
reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 201 },
|
||||
),
|
||||
).rejects.toThrow(StorageQuotaExceededError)
|
||||
|
||||
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
|
||||
expect(storageRows[0].used).toBe(50) // unchanged
|
||||
@@ -159,7 +202,12 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-grant', used: 0 })
|
||||
await insertOrgQuota(db, orgId, 1000, 800)
|
||||
|
||||
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 600 })).rejects.toThrow(StorageQuotaExceededError)
|
||||
await expect(
|
||||
reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 600 },
|
||||
),
|
||||
).rejects.toThrow(StorageQuotaExceededError)
|
||||
|
||||
const quotaRows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
expect(quotaRows[0].used).toBe(800)
|
||||
@@ -171,7 +219,12 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-full', used: 100 })
|
||||
await insertOrgQuota(db, orgId, 1000, 1000)
|
||||
|
||||
await expect(reserveStorageUsage(db, { orgId, storageId, bytes: 1 })).rejects.toThrow(StorageQuotaExceededError)
|
||||
await expect(
|
||||
reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 1 },
|
||||
),
|
||||
).rejects.toThrow(StorageQuotaExceededError)
|
||||
})
|
||||
|
||||
it('increments orgQuotas.used when within quota', async () => {
|
||||
@@ -180,7 +233,10 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-q-inc', used: 0 })
|
||||
await insertOrgQuota(db, orgId, 5000, 200)
|
||||
|
||||
await reserveStorageUsage(db, { orgId, storageId, bytes: 300 })
|
||||
await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 300 },
|
||||
)
|
||||
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
expect(rows[0].used).toBe(500)
|
||||
@@ -192,7 +248,10 @@ describe('reserveStorageUsage', () => {
|
||||
const storageId = await insertStorage(db, { id: 'st-s-inc', used: 100 })
|
||||
await insertOrgQuota(db, orgId, 5000, 100)
|
||||
|
||||
await reserveStorageUsage(db, { orgId, storageId, bytes: 400 })
|
||||
await reserveStorageUsage(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 400 },
|
||||
)
|
||||
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
|
||||
expect(rows[0].used).toBe(500)
|
||||
@@ -206,12 +265,16 @@ describe('reserveStorageUsage', () => {
|
||||
const cleaned: string[] = []
|
||||
|
||||
await expect(
|
||||
withStorageUsageReservation(db, { orgId, storageId, bytes: 300 }, async (ctx) => {
|
||||
ctx.onRollback(() => {
|
||||
cleaned.push('object-key')
|
||||
})
|
||||
throw new Error('persist failed')
|
||||
}),
|
||||
withStorageUsageReservation(
|
||||
{ quota: createQuotaRepo(db), storageUsage: createStorageUsageRepo(db) },
|
||||
{ orgId, storageId, bytes: 300 },
|
||||
async (ctx) => {
|
||||
ctx.onRollback(() => {
|
||||
cleaned.push('object-key')
|
||||
})
|
||||
throw new Error('persist failed')
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('persist failed')
|
||||
|
||||
expect(cleaned).toEqual(['object-key'])
|
||||
@@ -244,7 +307,7 @@ describe('reserveStorageUsage', () => {
|
||||
('draft-image', ${orgId}, 'ih_draft', 'draft.png', ${storageId}, 'ih/draft.png', 70, 'image/png', 'draft', 0, ${now})
|
||||
`)
|
||||
|
||||
await reconcileStorageUsage(db, orgId, [storageId])
|
||||
await createStorageUsageRepo(db).reconcile(orgId, [storageId])
|
||||
|
||||
const storageRows = await db.all<{ used: number }>(sql`SELECT used FROM storages WHERE id = ${storageId}`)
|
||||
expect(storageRows[0].used).toBe(350)
|
||||
@@ -0,0 +1,696 @@
|
||||
import { DirType, ObjectStatus } from '@shared/constants'
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, inArray, isNotNull, like, lt, ne, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { matters } from '../../db/schema'
|
||||
import { suggestRenamed } from '../../domain/matter-name-conflict'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
ConflictPlan,
|
||||
ConflictResolveOptions,
|
||||
ConflictStrategy,
|
||||
CopyMatterOptions,
|
||||
CreateMatterInput,
|
||||
Matter,
|
||||
MatterListFilters,
|
||||
MatterListResult,
|
||||
MatterRepo,
|
||||
UpdateMatterInput,
|
||||
} from '../../usecases/ports'
|
||||
import { NameConflictError } from '../../usecases/ports'
|
||||
import { createActivityRepo } from './activity'
|
||||
|
||||
type MatterRow = typeof matters.$inferSelect
|
||||
|
||||
function toMatter(row: MatterRow): Matter {
|
||||
return row
|
||||
}
|
||||
|
||||
function buildPath(parent: string, name: string): string {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
function descendantParentCondition(folderPath: string): SQL {
|
||||
const prefix = `${folderPath}/`
|
||||
return sql`SUBSTR(${matters.parent}, 1, LENGTH(${prefix})) = ${prefix}`
|
||||
}
|
||||
|
||||
function typeFilterCondition(typeFilter: string): SQL | undefined {
|
||||
switch (typeFilter) {
|
||||
case 'photos':
|
||||
return like(matters.type, 'image/%')
|
||||
case 'videos':
|
||||
return like(matters.type, 'video/%')
|
||||
case 'music':
|
||||
return like(matters.type, 'audio/%')
|
||||
case 'documents':
|
||||
return or(
|
||||
like(matters.type, 'application/pdf'),
|
||||
like(matters.type, 'application/msword'),
|
||||
like(matters.type, 'application/vnd.%'),
|
||||
like(matters.type, 'text/%'),
|
||||
)
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createMatterRepo(db: Database): MatterRepo {
|
||||
const activity = createActivityRepo(db)
|
||||
|
||||
async function getMatter(id: string, orgId: string): Promise<Matter | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
return rows[0] ? toMatter(rows[0]) : null
|
||||
}
|
||||
|
||||
function getDescendants(orgId: string, folderPath: string): Promise<MatterRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), descendantParentCondition(folderPath)))
|
||||
}
|
||||
|
||||
function getDirectChildren(orgId: string, folderPath: string): Promise<MatterRow[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath)))
|
||||
}
|
||||
|
||||
async function cascadeParentPath(orgId: string, oldPath: string, newPath: string): Promise<void> {
|
||||
// Direct children: parent = oldPath → parent = newPath
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ parent: newPath, updatedAt: new Date() })
|
||||
.where(and(eq(matters.orgId, orgId), eq(matters.parent, oldPath)))
|
||||
|
||||
// Deeper descendants: parent starts with 'oldPath/' → replace prefix.
|
||||
await db
|
||||
.update(matters)
|
||||
.set({
|
||||
parent: sql`${newPath} || SUBSTR(${matters.parent}, LENGTH(${oldPath}) + 1)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(matters.orgId, orgId), descendantParentCondition(oldPath)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the active sibling that would collide with `name` under `parent`.
|
||||
* Matching is case-insensitive (mirrors the DB's partial unique index on
|
||||
* LOWER(name)). `excludeId` lets rename/move skip the row being modified.
|
||||
*/
|
||||
async function findActiveConflict(
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
excludeId?: string,
|
||||
): Promise<Matter | null> {
|
||||
const conditions = [
|
||||
eq(matters.orgId, orgId),
|
||||
eq(matters.parent, parent),
|
||||
eq(matters.status, ObjectStatus.ACTIVE),
|
||||
sql`lower(${matters.name}) = lower(${name})`,
|
||||
]
|
||||
if (excludeId) conditions.push(ne(matters.id, excludeId))
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(...conditions))
|
||||
.limit(1)
|
||||
return rows[0] ? toMatter(rows[0]) : null
|
||||
}
|
||||
|
||||
async function findAvailableName(
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
excludeId: string | undefined,
|
||||
): Promise<string> {
|
||||
for (let i = 1; i <= 999; i++) {
|
||||
const candidate = suggestRenamed(name, i)
|
||||
const conflict = await findActiveConflict(orgId, parent, candidate, excludeId)
|
||||
if (!conflict) return candidate
|
||||
}
|
||||
throw new Error('Too many name conflicts to auto-rename')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a resolution plan WITHOUT side effects. Safe to call and discard.
|
||||
*
|
||||
* Throws NameConflictError when strategy='fail' or when 'replace' is rejected
|
||||
* because the incoming or existing row is a folder (not supported in v1).
|
||||
*/
|
||||
async function planConflictResolution(
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
strategy: ConflictStrategy,
|
||||
options: ConflictResolveOptions = {},
|
||||
): Promise<ConflictPlan> {
|
||||
const existing = await findActiveConflict(orgId, parent, name, options.excludeId)
|
||||
if (!existing) return { finalName: name, toTrash: null }
|
||||
|
||||
if (strategy === 'fail') {
|
||||
throw new NameConflictError(existing.name, existing.id)
|
||||
}
|
||||
|
||||
if (strategy === 'replace') {
|
||||
const incomingIsFolder = options.isFolder === true
|
||||
const existingIsFolder = existing.dirtype !== DirType.FILE
|
||||
if (incomingIsFolder || existingIsFolder) {
|
||||
throw new NameConflictError(existing.name, existing.id)
|
||||
}
|
||||
return { finalName: name, toTrash: existing }
|
||||
}
|
||||
|
||||
const renamed = await findAvailableName(orgId, parent, name, options.excludeId)
|
||||
return { finalName: renamed, toTrash: null }
|
||||
}
|
||||
|
||||
async function trashForReplace(orgId: string, existing: Matter, userId: string | undefined): Promise<void> {
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ status: ObjectStatus.TRASHED, trashedAt: now.getTime(), updatedAt: now })
|
||||
.where(and(eq(matters.id, existing.id), eq(matters.orgId, orgId)))
|
||||
|
||||
if (userId) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'replace',
|
||||
targetType: 'file',
|
||||
targetId: existing.id,
|
||||
targetName: existing.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute the side effects of a plan (trash the replaced row, log activity). */
|
||||
async function commitConflictPlan(orgId: string, plan: ConflictPlan, userId?: string): Promise<void> {
|
||||
if (plan.toTrash) {
|
||||
await trashForReplace(orgId, plan.toTrash, userId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience for the common case: plan + commit back-to-back. Callers that
|
||||
* need to interleave checks (e.g. quota) between plan and commit should use
|
||||
* planConflictResolution / commitConflictPlan directly — see confirmUpload.
|
||||
*/
|
||||
async function applyConflictResolution(
|
||||
orgId: string,
|
||||
parent: string,
|
||||
name: string,
|
||||
strategy: ConflictStrategy,
|
||||
options: ConflictResolveOptions = {},
|
||||
): Promise<string> {
|
||||
const plan = await planConflictResolution(orgId, parent, name, strategy, options)
|
||||
await commitConflictPlan(orgId, plan, options.userId)
|
||||
return plan.finalName
|
||||
}
|
||||
|
||||
function collectForPurge(orgId: string, idOrMatter: string): Promise<Matter[] | null>
|
||||
function collectForPurge(orgId: string, idOrMatter: Matter): Promise<Matter[]>
|
||||
async function collectForPurge(orgId: string, idOrMatter: string | Matter): Promise<Matter[] | null> {
|
||||
const existing = typeof idOrMatter === 'string' ? await getMatter(idOrMatter, orgId) : idOrMatter
|
||||
if (!existing) return null
|
||||
|
||||
if (existing.dirtype === DirType.FILE) return [existing]
|
||||
|
||||
const path = buildPath(existing.parent, existing.name)
|
||||
const children = await getDirectChildren(orgId, path)
|
||||
const descendants = await getDescendants(orgId, path)
|
||||
return [existing, ...children.map(toMatter), ...descendants.map(toMatter)]
|
||||
}
|
||||
|
||||
const repo: MatterRepo = {
|
||||
async create(input: CreateMatterInput): Promise<Matter> {
|
||||
const now = new Date()
|
||||
const isFolder = (input.dirtype ?? 0) !== DirType.FILE
|
||||
const parent = input.parent ?? ''
|
||||
|
||||
// Resolve name collisions against existing active siblings BEFORE inserting —
|
||||
// for folders this prevents duplicates at creation; for files it catches the
|
||||
// conflict before the client wastes a large S3 upload.
|
||||
const plan = await planConflictResolution(input.orgId, parent, input.name, input.onConflict ?? 'fail', {
|
||||
isFolder,
|
||||
userId: input.userId,
|
||||
})
|
||||
// Overwriting the incumbent for a draft-file 'replace' is deferred to
|
||||
// confirmUpload (which purges it after the new bytes land). That keeps a
|
||||
// failed/abandoned upload from destroying the existing file, and lets the
|
||||
// replace be charged as a net-size change. Folders, active creates, and
|
||||
// rename commit their plan immediately.
|
||||
const deferOverwrite = !isFolder && input.status === 'draft' && plan.toTrash !== null
|
||||
if (!deferOverwrite) {
|
||||
await commitConflictPlan(input.orgId, plan, input.userId)
|
||||
}
|
||||
const finalName = plan.finalName
|
||||
|
||||
const row: MatterRow = {
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
alias: nanoid(10),
|
||||
name: finalName,
|
||||
type: input.type,
|
||||
size: input.size ?? 0,
|
||||
dirtype: input.dirtype ?? 0,
|
||||
parent,
|
||||
object: input.object,
|
||||
storageId: input.storageId,
|
||||
status: input.status,
|
||||
trashedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(matters).values(row)
|
||||
|
||||
if (input.userId) {
|
||||
await activity.record({
|
||||
orgId: input.orgId,
|
||||
userId: input.userId,
|
||||
action: isFolder ? 'create' : 'upload',
|
||||
targetType: isFolder ? 'folder' : 'file',
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
})
|
||||
}
|
||||
|
||||
return toMatter(row)
|
||||
},
|
||||
|
||||
async list(orgId: string, filters: MatterListFilters): Promise<MatterListResult> {
|
||||
const offset = (filters.page - 1) * filters.pageSize
|
||||
if (filters.status === 'trashed' && !filters.search && !filters.typeFilter) {
|
||||
const roots = await repo.listTrashedRoots(orgId)
|
||||
return {
|
||||
items: roots.slice(offset, offset + filters.pageSize),
|
||||
total: roots.length,
|
||||
page: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
const conditions = [eq(matters.orgId, orgId), eq(matters.status, filters.status)]
|
||||
const typeCond = filters.typeFilter ? typeFilterCondition(filters.typeFilter) : undefined
|
||||
if (filters.search) {
|
||||
conditions.push(like(matters.name, `%${filters.search}%`))
|
||||
} else if (typeCond) {
|
||||
conditions.push(typeCond)
|
||||
conditions.push(eq(matters.dirtype, DirType.FILE))
|
||||
} else {
|
||||
conditions.push(eq(matters.parent, filters.parent ?? ''))
|
||||
}
|
||||
const where = and(...conditions)
|
||||
|
||||
const countRows = await db.select({ count: count() }).from(matters).where(where)
|
||||
const total = countRows[0]?.count ?? 0
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(where)
|
||||
.orderBy(desc(matters.dirtype), asc(matters.createdAt))
|
||||
.limit(filters.pageSize)
|
||||
.offset(offset)
|
||||
|
||||
return { items: items.map(toMatter), total, page: filters.page, pageSize: filters.pageSize }
|
||||
},
|
||||
|
||||
get(id, orgId) {
|
||||
return getMatter(id, orgId)
|
||||
},
|
||||
|
||||
async getMany(orgId, ids) {
|
||||
if (ids.length === 0) return []
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
|
||||
return rows.map(toMatter)
|
||||
},
|
||||
|
||||
async update(id, orgId, input: UpdateMatterInput, userId?: string): Promise<Matter | null> {
|
||||
const existing = await getMatter(id, orgId)
|
||||
if (!existing) return null
|
||||
|
||||
const now = new Date()
|
||||
const requestedName = input.name ?? existing.name
|
||||
const newParent = input.parent ?? existing.parent
|
||||
const isFolder = existing.dirtype !== DirType.FILE
|
||||
const renamed = input.name && input.name !== existing.name
|
||||
const moved = input.parent !== undefined && input.parent !== existing.parent
|
||||
|
||||
// Guard: reject before touching descendants. Must happen after rename/move
|
||||
// detection but before cascading path updates.
|
||||
if (isFolder && (renamed || moved)) {
|
||||
const oldPath = buildPath(existing.parent, existing.name)
|
||||
if (newParent === oldPath || newParent.startsWith(`${oldPath}/`)) {
|
||||
throw new Error('Cannot move a folder into itself or its subfolder')
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve name conflict in the destination parent. Excludes self so a no-op
|
||||
// rename (A → A) is allowed.
|
||||
const newName =
|
||||
renamed || moved
|
||||
? await applyConflictResolution(orgId, newParent, requestedName, input.onConflict ?? 'fail', {
|
||||
excludeId: existing.id,
|
||||
isFolder,
|
||||
userId,
|
||||
})
|
||||
: requestedName
|
||||
|
||||
if (isFolder && (renamed || moved)) {
|
||||
const oldPath = buildPath(existing.parent, existing.name)
|
||||
const newPath = buildPath(newParent, newName)
|
||||
await cascadeParentPath(orgId, oldPath, newPath)
|
||||
}
|
||||
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ name: newName, parent: newParent, updatedAt: now })
|
||||
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
|
||||
const updated = { ...existing, name: newName, parent: newParent, updatedAt: now }
|
||||
|
||||
if (userId) {
|
||||
const targetType = isFolder ? 'folder' : 'file'
|
||||
// Compare the final persisted state so auto-renames (from conflict resolution)
|
||||
// are recorded even when the user only asked to move.
|
||||
if (newName !== existing.name) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'rename',
|
||||
targetType,
|
||||
targetId: id,
|
||||
targetName: newName,
|
||||
metadata: { from: existing.name },
|
||||
})
|
||||
}
|
||||
if (newParent !== existing.parent) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'move',
|
||||
targetType,
|
||||
targetId: id,
|
||||
targetName: newName,
|
||||
metadata: { from: existing.parent, to: newParent },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return updated
|
||||
},
|
||||
|
||||
async copy(source, targetParent, newObject, opts: CopyMatterOptions = {}): Promise<Matter> {
|
||||
const now = new Date()
|
||||
const isFolder = source.dirtype !== DirType.FILE
|
||||
// Default to 'rename' for copy — copying "foo.pdf" into the same folder almost
|
||||
// always means "make a duplicate", so the Finder-style auto-rename is the
|
||||
// intuitive default when the caller didn't pick a strategy.
|
||||
const finalName = await applyConflictResolution(
|
||||
source.orgId,
|
||||
targetParent,
|
||||
source.name,
|
||||
opts.onConflict ?? 'rename',
|
||||
{
|
||||
isFolder,
|
||||
userId: opts.userId,
|
||||
},
|
||||
)
|
||||
|
||||
const row: MatterRow = {
|
||||
id: nanoid(),
|
||||
orgId: source.orgId,
|
||||
alias: nanoid(10),
|
||||
name: finalName,
|
||||
type: source.type,
|
||||
size: source.size,
|
||||
dirtype: source.dirtype,
|
||||
parent: targetParent,
|
||||
object: newObject,
|
||||
storageId: source.storageId,
|
||||
status: 'active',
|
||||
trashedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.insert(matters).values(row)
|
||||
|
||||
if (opts.userId) {
|
||||
await activity.record({
|
||||
orgId: source.orgId,
|
||||
userId: opts.userId,
|
||||
action: 'object_copy',
|
||||
targetType: isFolder ? 'folder' : 'file',
|
||||
targetId: row.id,
|
||||
targetName: row.name,
|
||||
metadata: { from: source.name, to: targetParent },
|
||||
})
|
||||
}
|
||||
|
||||
return toMatter(row)
|
||||
},
|
||||
|
||||
async delete(id, orgId): Promise<Matter | null> {
|
||||
const existing = await getMatter(id, orgId)
|
||||
if (!existing) return null
|
||||
|
||||
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
return existing
|
||||
},
|
||||
|
||||
async cancelDraft(id, orgId, userId?): Promise<Matter | null> {
|
||||
const existing = await getMatter(id, orgId)
|
||||
if (!existing || existing.status !== 'draft') return null
|
||||
|
||||
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft')))
|
||||
|
||||
if (userId) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'upload_cancel',
|
||||
targetType: 'file',
|
||||
targetId: existing.id,
|
||||
targetName: existing.name,
|
||||
})
|
||||
}
|
||||
|
||||
return existing
|
||||
},
|
||||
|
||||
async trash(orgId, id, userId?): Promise<Matter | null> {
|
||||
const existing = await getMatter(id, orgId)
|
||||
if (!existing) return null
|
||||
if (existing.status === 'trashed') return existing
|
||||
|
||||
const now = new Date()
|
||||
const nowTs = now.getTime()
|
||||
const allIds = [existing.id]
|
||||
|
||||
if (existing.dirtype !== DirType.FILE) {
|
||||
const path = buildPath(existing.parent, existing.name)
|
||||
const children = await getDirectChildren(orgId, path)
|
||||
const descendants = await getDescendants(orgId, path)
|
||||
allIds.push(...children.map((m) => m.id), ...descendants.map((m) => m.id))
|
||||
}
|
||||
|
||||
for (const targetId of allIds) {
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ status: 'trashed', trashedAt: nowTs, updatedAt: now })
|
||||
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'active')))
|
||||
}
|
||||
const trashed = { ...existing, status: 'trashed', trashedAt: nowTs, updatedAt: now }
|
||||
|
||||
if (userId) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'delete',
|
||||
targetType: existing.dirtype !== DirType.FILE ? 'folder' : 'file',
|
||||
targetId: existing.id,
|
||||
targetName: existing.name,
|
||||
})
|
||||
}
|
||||
|
||||
return trashed
|
||||
},
|
||||
|
||||
async restore(orgId, id, userId?, onConflict: ConflictStrategy = 'fail'): Promise<Matter | null> {
|
||||
const existing = await getMatter(id, orgId)
|
||||
if (!existing) return null
|
||||
if (existing.status !== 'trashed') return existing
|
||||
|
||||
// A same-named active item may have been created in the original parent
|
||||
// while this one sat in trash. Resolve before touching descendants so a
|
||||
// rejection doesn't leave folders half-restored.
|
||||
const isFolder = existing.dirtype !== DirType.FILE
|
||||
const finalName = await applyConflictResolution(orgId, existing.parent, existing.name, onConflict, {
|
||||
excludeId: existing.id,
|
||||
isFolder,
|
||||
userId,
|
||||
})
|
||||
|
||||
const now = new Date()
|
||||
const allIds = [existing.id]
|
||||
|
||||
if (isFolder) {
|
||||
const path = buildPath(existing.parent, existing.name)
|
||||
const children = await getDirectChildren(orgId, path)
|
||||
const descendants = await getDescendants(orgId, path)
|
||||
allIds.push(...children.map((m) => m.id), ...descendants.map((m) => m.id))
|
||||
}
|
||||
|
||||
// Rename + cascade parent paths BEFORE activation. While everything is still
|
||||
// trashed, these writes cannot violate the active-name unique index, and no
|
||||
// reader ever sees descendants with stale paths in an active state.
|
||||
if (finalName !== existing.name) {
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ name: finalName, updatedAt: now })
|
||||
.where(and(eq(matters.id, existing.id), eq(matters.orgId, orgId)))
|
||||
if (isFolder) {
|
||||
const oldPath = buildPath(existing.parent, existing.name)
|
||||
const newPath = buildPath(existing.parent, finalName)
|
||||
await cascadeParentPath(orgId, oldPath, newPath)
|
||||
}
|
||||
}
|
||||
|
||||
for (const targetId of allIds) {
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ status: 'active', trashedAt: null, updatedAt: now })
|
||||
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'trashed')))
|
||||
}
|
||||
|
||||
const restored = { ...existing, name: finalName, status: 'active', trashedAt: null, updatedAt: now }
|
||||
|
||||
if (userId) {
|
||||
await activity.record({
|
||||
orgId,
|
||||
userId,
|
||||
action: 'restore',
|
||||
targetType: isFolder ? 'folder' : 'file',
|
||||
targetId: existing.id,
|
||||
targetName: finalName,
|
||||
})
|
||||
}
|
||||
|
||||
return restored
|
||||
},
|
||||
|
||||
collectForPurge,
|
||||
|
||||
async purge(orgId, ids): Promise<void> {
|
||||
for (const id of ids) {
|
||||
await db.delete(matters).where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
}
|
||||
},
|
||||
|
||||
async listActiveDescendants(orgId, parentPath): Promise<Matter[]> {
|
||||
// Exact-prefix match (SUBSTR), not LIKE — folder names can contain `_`/`%`,
|
||||
// which LIKE would treat as wildcards and over-match. Matches the rest of this repo.
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(
|
||||
and(eq(matters.orgId, orgId), eq(matters.status, ObjectStatus.ACTIVE), descendantParentCondition(parentPath)),
|
||||
)
|
||||
return rows.map(toMatter)
|
||||
},
|
||||
|
||||
async trashByIds(orgId, ids): Promise<void> {
|
||||
if (ids.length === 0) return
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ status: ObjectStatus.TRASHED, trashedAt: Date.now(), updatedAt: new Date() })
|
||||
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
|
||||
},
|
||||
|
||||
async restoreActiveByIds(orgId, ids): Promise<void> {
|
||||
if (ids.length === 0) return
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ status: ObjectStatus.ACTIVE, trashedAt: null, updatedAt: new Date() })
|
||||
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
|
||||
},
|
||||
|
||||
async touch(orgId, id): Promise<void> {
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
},
|
||||
|
||||
async applyUpload(orgId, id, fields): Promise<void> {
|
||||
await db
|
||||
.update(matters)
|
||||
.set({ type: fields.type, size: fields.size, object: fields.object, updatedAt: new Date() })
|
||||
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
|
||||
},
|
||||
|
||||
async listTrashedRoots(orgId): Promise<Matter[]> {
|
||||
const all = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), eq(matters.status, 'trashed')))
|
||||
|
||||
const trashedPaths = new Set(all.map((m) => buildPath(m.parent, m.name)))
|
||||
return all
|
||||
.filter((m) => !trashedPaths.has(m.parent))
|
||||
.sort((a, b) => {
|
||||
const aTrashedAt = a.trashedAt ?? 0
|
||||
const bTrashedAt = b.trashedAt ?? 0
|
||||
if (aTrashedAt !== bTrashedAt) return bTrashedAt - aTrashedAt
|
||||
return b.createdAt.getTime() - a.createdAt.getTime()
|
||||
})
|
||||
.map(toMatter)
|
||||
},
|
||||
|
||||
async listOrgIdsWithExpiredTrash(cutoff): Promise<string[]> {
|
||||
const rows = await db
|
||||
.selectDistinct({ orgId: matters.orgId })
|
||||
.from(matters)
|
||||
.where(and(eq(matters.status, 'trashed'), isNotNull(matters.trashedAt), lt(matters.trashedAt, cutoff)))
|
||||
return rows.map((r) => r.orgId)
|
||||
},
|
||||
|
||||
findActiveConflict(orgId, parent, name, excludeId) {
|
||||
return findActiveConflict(orgId, parent, name, excludeId)
|
||||
},
|
||||
|
||||
planConflictResolution(orgId, parent, name, strategy, options) {
|
||||
return planConflictResolution(orgId, parent, name, strategy, options)
|
||||
},
|
||||
|
||||
commitConflictPlan(orgId, plan, userId) {
|
||||
return commitConflictPlan(orgId, plan, userId)
|
||||
},
|
||||
|
||||
applyConflictResolution(orgId, parent, name, strategy, options) {
|
||||
return applyConflictResolution(orgId, parent, name, strategy, options)
|
||||
},
|
||||
|
||||
async activateDraft(id, orgId, finalName, now): Promise<boolean> {
|
||||
const updated = await db
|
||||
.update(matters)
|
||||
.set({ name: finalName, status: 'active', updatedAt: now })
|
||||
.where(and(eq(matters.id, id), eq(matters.orgId, orgId), eq(matters.status, 'draft')))
|
||||
.returning({ id: matters.id })
|
||||
return updated.length > 0
|
||||
},
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { member } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { MemberCountRepo } from '../../usecases/ports'
|
||||
|
||||
export function createMemberCountRepo(db: Database): MemberCountRepo {
|
||||
return {
|
||||
async countUserOrgs(userId) {
|
||||
const rows = await db.select({ id: member.id }).from(member).where(eq(member.userId, userId))
|
||||
return rows.length
|
||||
},
|
||||
}
|
||||
}
|
||||
+42
-48
@@ -1,14 +1,8 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import {
|
||||
createNotification,
|
||||
listNotifications,
|
||||
markAllAsRead,
|
||||
markAsRead,
|
||||
unreadCount,
|
||||
} from '../services/notification.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import * as authSchema from '../../db/auth-schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createNotificationRepo } from './notification.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
@@ -30,7 +24,7 @@ describe('createNotification', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'You got a share' })
|
||||
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'You got a share' })
|
||||
|
||||
expect(n.id).toBeDefined()
|
||||
expect(n.userId).toBe(userId)
|
||||
@@ -45,7 +39,7 @@ describe('createNotification', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n = await createNotification(db, {
|
||||
const n = await createNotificationRepo(db).create({
|
||||
userId,
|
||||
type: 'share_received',
|
||||
title: 'Test',
|
||||
@@ -67,7 +61,7 @@ describe('listNotifications', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
|
||||
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20 })
|
||||
|
||||
expect(result.items).toHaveLength(0)
|
||||
expect(result.total).toBe(0)
|
||||
@@ -79,14 +73,14 @@ describe('listNotifications', () => {
|
||||
const userId = await insertUser(db)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createNotification(db, { userId, type: 'share_received', title: `Notification ${i}` })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: `Notification ${i}` })
|
||||
}
|
||||
|
||||
const page1 = await listNotifications(db, userId, { page: 1, pageSize: 3 })
|
||||
const page1 = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 3 })
|
||||
expect(page1.items).toHaveLength(3)
|
||||
expect(page1.total).toBe(5)
|
||||
|
||||
const page2 = await listNotifications(db, userId, { page: 2, pageSize: 3 })
|
||||
const page2 = await createNotificationRepo(db).list(userId, { page: 2, pageSize: 3 })
|
||||
expect(page2.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
@@ -94,11 +88,11 @@ describe('listNotifications', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
const n1 = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
|
||||
await createNotificationRepo(db).markAsRead(userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
|
||||
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20 })
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.unreadCount).toBe(1)
|
||||
})
|
||||
@@ -107,11 +101,11 @@ describe('listNotifications', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
const n1 = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
|
||||
await createNotificationRepo(db).markAsRead(userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20, unreadOnly: true })
|
||||
const result = await createNotificationRepo(db).list(userId, { page: 1, pageSize: 20, unreadOnly: true })
|
||||
expect(result.items).toHaveLength(1)
|
||||
expect(result.items[0].title).toBe('B')
|
||||
})
|
||||
@@ -121,9 +115,9 @@ describe('listNotifications', () => {
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'share_received', title: 'For user1' })
|
||||
await createNotificationRepo(db).create({ userId: user1, type: 'share_received', title: 'For user1' })
|
||||
|
||||
const result = await listNotifications(db, user2, { page: 1, pageSize: 20 })
|
||||
const result = await createNotificationRepo(db).list(user2, { page: 1, pageSize: 20 })
|
||||
expect(result.items).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -132,15 +126,15 @@ describe('markAsRead', () => {
|
||||
it('marks a notification as read (idempotent)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
|
||||
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'Test' })
|
||||
|
||||
const first = await markAsRead(db, userId, n.id)
|
||||
const first = await createNotificationRepo(db).markAsRead(userId, n.id)
|
||||
expect(first).toBe(true)
|
||||
|
||||
const second = await markAsRead(db, userId, n.id)
|
||||
const second = await createNotificationRepo(db).markAsRead(userId, n.id)
|
||||
expect(second).toBe(true)
|
||||
|
||||
const count = await unreadCount(db, userId)
|
||||
const count = await createNotificationRepo(db).unreadCount(userId)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
@@ -148,12 +142,12 @@ describe('markAsRead', () => {
|
||||
const { db } = await createTestApp()
|
||||
const owner = await insertUser(db)
|
||||
const other = await insertUser(db)
|
||||
const n = await createNotification(db, { userId: owner, type: 'share_received', title: 'Test' })
|
||||
const n = await createNotificationRepo(db).create({ userId: owner, type: 'share_received', title: 'Test' })
|
||||
|
||||
const result = await markAsRead(db, other, n.id)
|
||||
const result = await createNotificationRepo(db).markAsRead(other, n.id)
|
||||
expect(result).toBe(false)
|
||||
|
||||
const count = await unreadCount(db, owner)
|
||||
const count = await createNotificationRepo(db).unreadCount(owner)
|
||||
expect(count).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -163,13 +157,13 @@ describe('markAllAsRead', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
|
||||
|
||||
const result = await markAllAsRead(db, userId)
|
||||
const result = await createNotificationRepo(db).markAllAsRead(userId)
|
||||
expect(result.count).toBe(2)
|
||||
|
||||
const count = await unreadCount(db, userId)
|
||||
const count = await createNotificationRepo(db).unreadCount(userId)
|
||||
expect(count).toBe(0)
|
||||
})
|
||||
|
||||
@@ -178,20 +172,20 @@ describe('markAllAsRead', () => {
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId: user2, type: 'share_received', title: 'B' })
|
||||
await createNotificationRepo(db).create({ userId: user1, type: 'share_received', title: 'A' })
|
||||
await createNotificationRepo(db).create({ userId: user2, type: 'share_received', title: 'B' })
|
||||
|
||||
await markAllAsRead(db, user1)
|
||||
await createNotificationRepo(db).markAllAsRead(user1)
|
||||
|
||||
expect(await unreadCount(db, user1)).toBe(0)
|
||||
expect(await unreadCount(db, user2)).toBe(1)
|
||||
expect(await createNotificationRepo(db).unreadCount(user1)).toBe(0)
|
||||
expect(await createNotificationRepo(db).unreadCount(user2)).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when nothing to mark', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await markAllAsRead(db, userId)
|
||||
const result = await createNotificationRepo(db).markAllAsRead(userId)
|
||||
expect(result.count).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -201,14 +195,14 @@ describe('unreadCount', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(0)
|
||||
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(0)
|
||||
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
const n = await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'A' })
|
||||
await createNotificationRepo(db).create({ userId, type: 'share_received', title: 'B' })
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(2)
|
||||
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(2)
|
||||
|
||||
await markAsRead(db, userId, n.id)
|
||||
expect(await unreadCount(db, userId)).toBe(1)
|
||||
await createNotificationRepo(db).markAsRead(userId, n.id)
|
||||
expect(await createNotificationRepo(db).unreadCount(userId)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { and, count, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { notifications } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { NotificationRecord, NotificationRepo } from '../../usecases/ports'
|
||||
|
||||
type NotificationRow = typeof notifications.$inferSelect
|
||||
|
||||
function toRecord(row: NotificationRow): NotificationRecord {
|
||||
return row as NotificationRecord
|
||||
}
|
||||
|
||||
export function createNotificationRepo(db: Database): NotificationRepo {
|
||||
return {
|
||||
async create(input) {
|
||||
const row: NotificationRow = {
|
||||
id: nanoid(),
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
body: input.body ?? '',
|
||||
refType: input.refType ?? null,
|
||||
refId: input.refId ?? null,
|
||||
metadata: input.metadata ?? null,
|
||||
readAt: null,
|
||||
createdAt: new Date(),
|
||||
}
|
||||
await db.insert(notifications).values(row)
|
||||
return toRecord(row)
|
||||
},
|
||||
|
||||
async list(userId, opts) {
|
||||
const { page, pageSize, unreadOnly } = opts
|
||||
const offset = (page - 1) * pageSize
|
||||
const baseCondition = unreadOnly
|
||||
? and(eq(notifications.userId, userId), isNull(notifications.readAt))
|
||||
: eq(notifications.userId, userId)
|
||||
|
||||
const [items, totalRows, unreadRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(notifications)
|
||||
.where(baseCondition)
|
||||
.orderBy(desc(notifications.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db.select({ count: count() }).from(notifications).where(baseCondition),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt))),
|
||||
])
|
||||
|
||||
return {
|
||||
items: items.map(toRecord),
|
||||
total: totalRows[0]?.count ?? 0,
|
||||
unreadCount: unreadRows[0]?.count ?? 0,
|
||||
}
|
||||
},
|
||||
|
||||
async markAsRead(userId, id) {
|
||||
const rows = await db
|
||||
.select({ id: notifications.id, readAt: notifications.readAt })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.id, id), eq(notifications.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!rows[0]) return false
|
||||
if (!rows[0].readAt) {
|
||||
await db.update(notifications).set({ readAt: new Date() }).where(eq(notifications.id, id))
|
||||
}
|
||||
return true
|
||||
},
|
||||
|
||||
async markAllAsRead(userId) {
|
||||
const unread = await db
|
||||
.select({ id: notifications.id })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
||||
|
||||
if (unread.length === 0) return { count: 0 }
|
||||
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ readAt: new Date() })
|
||||
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
||||
|
||||
return { count: unread.length }
|
||||
},
|
||||
|
||||
async unreadCount(userId) {
|
||||
const rows = await db
|
||||
.select({ count: count() })
|
||||
.from(notifications)
|
||||
.where(and(eq(notifications.userId, userId), isNull(notifications.readAt)))
|
||||
return rows[0]?.count ?? 0
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { objectUploadSessions } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ObjectUploadSessionRecord, ObjectUploadSessionRepo } from '../../usecases/ports'
|
||||
|
||||
const SESSION_TTL_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
type SessionRow = typeof objectUploadSessions.$inferSelect
|
||||
|
||||
function toRecord(row: SessionRow): ObjectUploadSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
objectId: row.objectId,
|
||||
uploadId: row.uploadId,
|
||||
partSize: row.partSize,
|
||||
status: row.status as ObjectUploadSessionRecord['status'],
|
||||
storageKey: row.storageKey,
|
||||
expiresAt: row.expiresAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createObjectUploadSessionRepo(db: Database): ObjectUploadSessionRepo {
|
||||
return {
|
||||
async create(input) {
|
||||
const now = new Date()
|
||||
const row: typeof objectUploadSessions.$inferInsert = {
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
objectId: input.objectId,
|
||||
storageId: input.storageId,
|
||||
storageKey: input.storageKey,
|
||||
uploadId: input.uploadId,
|
||||
partSize: input.partSize,
|
||||
status: 'active',
|
||||
createdBy: input.actorId,
|
||||
expiresAt: new Date(now.getTime() + SESSION_TTL_MS),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(objectUploadSessions).values(row)
|
||||
return toRecord(row as SessionRow)
|
||||
},
|
||||
|
||||
async get(orgId, objectId, id) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(objectUploadSessions)
|
||||
.where(
|
||||
and(
|
||||
eq(objectUploadSessions.id, id),
|
||||
eq(objectUploadSessions.orgId, orgId),
|
||||
eq(objectUploadSessions.objectId, objectId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
return row ? toRecord(row) : null
|
||||
},
|
||||
|
||||
async setStatus(id, status) {
|
||||
await db
|
||||
.update(objectUploadSessions)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(eq(objectUploadSessions.id, id))
|
||||
},
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { findPersonalOrg } from './org.js'
|
||||
import * as authSchema from '../../db/auth-schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createOrgRepo } from './org.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
@@ -48,7 +48,7 @@ describe('findPersonalOrg', () => {
|
||||
const orgId = await insertOrg(db, { slug: `personal-${userId}` })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
const result = await createOrgRepo(db).findPersonalOrg(userId)
|
||||
expect(result).toBe(orgId)
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ describe('findPersonalOrg', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
const result = await createOrgRepo(db).findPersonalOrg(userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('findPersonalOrg', () => {
|
||||
const orgId = await insertOrg(db, { slug: 'some-team-org' })
|
||||
await insertMember(db, orgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
const result = await createOrgRepo(db).findPersonalOrg(userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('findPersonalOrg', () => {
|
||||
await insertMember(db, teamOrgId, userId)
|
||||
await insertMember(db, personalOrgId, userId)
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
const result = await createOrgRepo(db).findPersonalOrg(userId)
|
||||
expect(result).toBe(personalOrgId)
|
||||
})
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('findPersonalOrg', () => {
|
||||
await insertOrg(db, { slug: `personal-${userId}` })
|
||||
// No member row inserted — membership is load-bearing
|
||||
|
||||
const result = await findPersonalOrg(db, userId)
|
||||
const result = await createOrgRepo(db).findPersonalOrg(userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { getMemberRole, isPersonalOrg } from './org.js'
|
||||
import * as authSchema from '../../db/auth-schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createOrgRepo } from './org.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
@@ -47,7 +47,7 @@ describe('getMemberRole', () => {
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'owner')
|
||||
|
||||
const result = await getMemberRole(db, orgId, userId)
|
||||
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
|
||||
expect(result).toBe('owner')
|
||||
})
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('getMemberRole', () => {
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'editor')
|
||||
|
||||
const result = await getMemberRole(db, orgId, userId)
|
||||
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
|
||||
expect(result).toBe('editor')
|
||||
})
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('getMemberRole', () => {
|
||||
const orgId = await insertOrg(db)
|
||||
await insertMember(db, orgId, userId, 'viewer')
|
||||
|
||||
const result = await getMemberRole(db, orgId, userId)
|
||||
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
|
||||
expect(result).toBe('viewer')
|
||||
})
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('getMemberRole', () => {
|
||||
const userId = await insertUser(db)
|
||||
const orgId = await insertOrg(db)
|
||||
|
||||
const result = await getMemberRole(db, orgId, userId)
|
||||
const result = await createOrgRepo(db).getMemberRole(orgId, userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('getMemberRole', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const result = await getMemberRole(db, 'nonexistent-org', userId)
|
||||
const result = await createOrgRepo(db).getMemberRole('nonexistent-org', userId)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('getMemberRole', () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = await insertOrg(db)
|
||||
|
||||
const result = await getMemberRole(db, orgId, 'nonexistent-user')
|
||||
const result = await createOrgRepo(db).getMemberRole(orgId, 'nonexistent-user')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
@@ -104,8 +104,8 @@ describe('getMemberRole', () => {
|
||||
await insertMember(db, orgA, userId, 'editor')
|
||||
await insertMember(db, orgB, userId, 'viewer')
|
||||
|
||||
expect(await getMemberRole(db, orgA, userId)).toBe('editor')
|
||||
expect(await getMemberRole(db, orgB, userId)).toBe('viewer')
|
||||
expect(await createOrgRepo(db).getMemberRole(orgA, userId)).toBe('editor')
|
||||
expect(await createOrgRepo(db).getMemberRole(orgB, userId)).toBe('viewer')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('isPersonalOrg', () => {
|
||||
const userId = nanoid()
|
||||
const orgId = await insertOrg(db, { slug: `personal-${userId}` })
|
||||
|
||||
const result = await isPersonalOrg(db, orgId)
|
||||
const result = await createOrgRepo(db).isPersonalOrg(orgId)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ describe('isPersonalOrg', () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = await insertOrg(db, { slug: 'team-org-slug' })
|
||||
|
||||
const result = await isPersonalOrg(db, orgId)
|
||||
const result = await createOrgRepo(db).isPersonalOrg(orgId)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
@@ -131,14 +131,14 @@ describe('isPersonalOrg', () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = await insertOrg(db, { slug: 'personal' })
|
||||
|
||||
const result = await isPersonalOrg(db, orgId)
|
||||
const result = await createOrgRepo(db).isPersonalOrg(orgId)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when the org does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
|
||||
const result = await isPersonalOrg(db, 'nonexistent-org-id')
|
||||
const result = await createOrgRepo(db).isPersonalOrg('nonexistent-org-id')
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
@@ -146,7 +146,7 @@ describe('isPersonalOrg', () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = await insertOrg(db, { slug: 'team-personal-space' })
|
||||
|
||||
const result = await isPersonalOrg(db, orgId)
|
||||
const result = await createOrgRepo(db).isPersonalOrg(orgId)
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { member, organization } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { OrgRepo } from '../../usecases/ports'
|
||||
|
||||
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
|
||||
|
||||
export function createOrgRepo(db: Database): OrgRepo {
|
||||
// Find the user's personal org, if they still belong to it. The personal org
|
||||
// slug is a deterministic `personal-${user.id}`; filter on the indexed UNIQUE
|
||||
// slug then verify the member row still exists (an admin can revoke access by
|
||||
// deleting the member row without deleting the org).
|
||||
async function findPersonalOrg(userId: string): Promise<string | null> {
|
||||
const rows = await db
|
||||
.select({ orgId: organization.id })
|
||||
.from(organization)
|
||||
.innerJoin(member, and(eq(member.organizationId, organization.id), eq(member.userId, userId)))
|
||||
.where(eq(organization.slug, `personal-${userId}`))
|
||||
.limit(1)
|
||||
return rows[0]?.orgId ?? null
|
||||
}
|
||||
|
||||
async function getMemberRole(orgId: string, userId: string): Promise<string | null> {
|
||||
const rows = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
|
||||
.limit(1)
|
||||
return rows[0]?.role ?? null
|
||||
}
|
||||
|
||||
async function isPersonalOrg(orgId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ slug: organization.slug })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, orgId))
|
||||
.limit(1)
|
||||
return (rows[0]?.slug ?? '').startsWith('personal-')
|
||||
}
|
||||
|
||||
// Read access: any membership role, or the user's own personal org. Never
|
||||
// grants access to another user's personal org.
|
||||
async function canReadOrg(userId: string, orgId: string): Promise<boolean> {
|
||||
const role = await getMemberRole(orgId, userId)
|
||||
if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.viewer
|
||||
return orgId === (await findPersonalOrg(userId))
|
||||
}
|
||||
|
||||
// Write access: editor/owner membership, or the user's own personal org.
|
||||
// Checking ownership via findPersonalOrg (not isPersonalOrg) is load-bearing:
|
||||
// a request-supplied orgId must never write into another user's personal space.
|
||||
async function canWriteToOrg(userId: string, orgId: string): Promise<boolean> {
|
||||
const role = await getMemberRole(orgId, userId)
|
||||
if (role !== null) return (ROLE_LEVELS[role] ?? 0) >= ROLE_LEVELS.editor
|
||||
return orgId === (await findPersonalOrg(userId))
|
||||
}
|
||||
|
||||
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, isPersonalOrg }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { user } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ProfileRepo } from '../../usecases/ports'
|
||||
|
||||
export function createProfileRepo(db: Database): ProfileRepo {
|
||||
return {
|
||||
async getUserByUsername(username) {
|
||||
const rows = await db
|
||||
.select({ username: user.username, name: user.name, image: user.image })
|
||||
.from(user)
|
||||
.where(eq(user.username, username))
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row?.username) return null
|
||||
return { username: row.username, name: row.name, image: row.image ?? null }
|
||||
},
|
||||
|
||||
async setAvatar(userId, image) {
|
||||
await db.update(user).set({ image }).where(eq(user.id, userId))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,9 @@
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
consumeTrafficIfQuotaAllows,
|
||||
getEffectiveQuota,
|
||||
getEffectiveQuotasByOrg,
|
||||
hasQuotaForBytes,
|
||||
hasTrafficQuotaForBytes,
|
||||
incrementUsageIfEffectiveQuotaAllows,
|
||||
refundTraffic,
|
||||
resetExpiredTrafficQuotas,
|
||||
} from './effective-quota.js'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createQuotaRepo } from './quota.js'
|
||||
|
||||
describe('effective quota', () => {
|
||||
it('returns storage and traffic quota state', async () => {
|
||||
@@ -34,15 +25,17 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', 'free-traffic-plan', 2000, 'active', new Date('2026-05-06T00:00:00Z'), 'Free'),
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, new Date('2026-05-06T00:00:00Z'))).resolves.toMatchObject({
|
||||
orgId,
|
||||
baseQuota: 1000,
|
||||
quota: 1000,
|
||||
used: 250,
|
||||
trafficQuota: 2000,
|
||||
trafficUsed: 500,
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, new Date('2026-05-06T00:00:00Z'))).resolves.toMatchObject(
|
||||
{
|
||||
orgId,
|
||||
baseQuota: 1000,
|
||||
quota: 1000,
|
||||
used: 250,
|
||||
trafficQuota: 2000,
|
||||
trafficUsed: 500,
|
||||
trafficPeriod: '2026-05',
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('adds active entitlement bytes to effective storage and traffic quota', async () => {
|
||||
@@ -70,7 +63,7 @@ describe('effective quota', () => {
|
||||
},
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
baseQuota: 1000,
|
||||
entitlementQuota: 300,
|
||||
quota: 1300,
|
||||
@@ -104,7 +97,7 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', 'order-traffic-pack', 700, 'active', now),
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
baseQuota: 3000,
|
||||
entitlementQuota: 500,
|
||||
quota: 3500,
|
||||
@@ -138,7 +131,7 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', `stripe_subscription:sub_traffic:${orgId}`, 4000, 'active', now),
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
baseQuota: 3000,
|
||||
quota: 3000,
|
||||
baseTrafficQuota: 4000,
|
||||
@@ -170,7 +163,7 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', 'order-traffic-pack-2', 200, 'active', now, 'Burst Pack'),
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
entitlementQuota: 800,
|
||||
quota: 3800,
|
||||
entitlementTrafficQuota: 900,
|
||||
@@ -217,7 +210,7 @@ describe('effective quota', () => {
|
||||
},
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
currentPlan: {
|
||||
sourceId,
|
||||
packageId: 'pkg-team',
|
||||
@@ -263,7 +256,7 @@ describe('effective quota', () => {
|
||||
},
|
||||
])
|
||||
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
baseQuota: 0,
|
||||
entitlementQuota: 0,
|
||||
quota: 0,
|
||||
@@ -286,7 +279,7 @@ describe('effective quota', () => {
|
||||
trafficPeriod: '2026-04',
|
||||
})
|
||||
|
||||
const quota = await getEffectiveQuota(db, orgId, new Date('2026-05-01T00:00:00Z'))
|
||||
const quota = await createQuotaRepo(db).getEffectiveQuota(orgId, new Date('2026-05-01T00:00:00Z'))
|
||||
expect(quota.trafficUsed).toBe(0)
|
||||
expect(quota.trafficPeriod).toBe('2026-05')
|
||||
|
||||
@@ -323,7 +316,7 @@ describe('effective quota', () => {
|
||||
},
|
||||
])
|
||||
|
||||
await resetExpiredTrafficQuotas(db, new Date('2026-05-01T00:00:00Z'))
|
||||
await createQuotaRepo(db).resetExpiredTrafficQuotas(new Date('2026-05-01T00:00:00Z'))
|
||||
|
||||
const stale = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, staleOrg))
|
||||
expect(stale[0].trafficUsed).toBe(0)
|
||||
@@ -363,12 +356,12 @@ describe('effective quota', () => {
|
||||
entitlement(planOrg, 'traffic', 'order-traffic-pack', 700, 'active', now, 'Traffic Boost'),
|
||||
])
|
||||
|
||||
const batch = await getEffectiveQuotasByOrg(db, [planOrg, staleOrg, emptyOrg], now)
|
||||
const batch = await createQuotaRepo(db).getEffectiveQuotasByOrg([planOrg, staleOrg, emptyOrg], now)
|
||||
|
||||
expect(batch.size).toBe(3)
|
||||
// Batch result must match the per-org function exactly.
|
||||
for (const orgId of [planOrg, staleOrg, emptyOrg]) {
|
||||
expect(batch.get(orgId)).toEqual(await getEffectiveQuota(db, orgId, now))
|
||||
expect(batch.get(orgId)).toEqual(await createQuotaRepo(db).getEffectiveQuota(orgId, now))
|
||||
}
|
||||
|
||||
expect(batch.get(planOrg)).toMatchObject({
|
||||
@@ -412,7 +405,7 @@ describe('effective quota', () => {
|
||||
.insert(orgQuotaEntitlements)
|
||||
.values(entitlement(taggedOrg, 'storage', `stripe_subscription:sub:${taggedOrg}`, 3000, 'active', now, 'Team'))
|
||||
|
||||
const batch = await getEffectiveQuotasByOrg(db, orgIds, now)
|
||||
const batch = await createQuotaRepo(db).getEffectiveQuotasByOrg(orgIds, now)
|
||||
|
||||
expect(batch.size).toBe(200)
|
||||
expect(batch.get(orgIds[0])).toMatchObject({ used: 0, quota: 0 })
|
||||
@@ -422,7 +415,9 @@ describe('effective quota', () => {
|
||||
|
||||
it('returns an empty map for no orgs', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await expect(getEffectiveQuotasByOrg(db, [], new Date('2026-05-06T00:00:00Z'))).resolves.toEqual(new Map())
|
||||
await expect(createQuotaRepo(db).getEffectiveQuotasByOrg([], new Date('2026-05-06T00:00:00Z'))).resolves.toEqual(
|
||||
new Map(),
|
||||
)
|
||||
})
|
||||
|
||||
it('consumes traffic within the monthly quota and rejects overage', async () => {
|
||||
@@ -442,9 +437,9 @@ describe('effective quota', () => {
|
||||
.insert(orgQuotaEntitlements)
|
||||
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
|
||||
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(1000)
|
||||
@@ -470,8 +465,8 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', 'traffic-overage', 500, 'active', now),
|
||||
])
|
||||
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 201, now)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 400, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 201, now)).resolves.toBe(false)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(1300)
|
||||
@@ -498,8 +493,8 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'traffic', 'traffic-pack', 500, 'active', now),
|
||||
])
|
||||
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('allows subscription traffic overage when the active plan has an overage price', async () => {
|
||||
@@ -532,10 +527,10 @@ describe('effective quota', () => {
|
||||
),
|
||||
])
|
||||
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(getEffectiveQuota(db, orgId, now)).resolves.toMatchObject({
|
||||
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).getEffectiveQuota(orgId, now)).resolves.toMatchObject({
|
||||
currentPlan: {
|
||||
name: 'Pro Plan',
|
||||
trafficBytes: 2000,
|
||||
@@ -561,9 +556,9 @@ describe('effective quota', () => {
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'traffic', 'traffic-zero-base', 500, 'active', now))
|
||||
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1, now)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 100, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1, now)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('refunds current monthly traffic usage', async () => {
|
||||
@@ -580,7 +575,7 @@ describe('effective quota', () => {
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
|
||||
await refundTraffic(db, orgId, 300, now)
|
||||
await createQuotaRepo(db).refundTraffic(orgId, 300, now)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(400)
|
||||
@@ -600,7 +595,7 @@ describe('effective quota', () => {
|
||||
trafficPeriod: '2026-05',
|
||||
})
|
||||
|
||||
await refundTraffic(db, orgId, 300, now)
|
||||
await createQuotaRepo(db).refundTraffic(orgId, 300, now)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(0)
|
||||
@@ -623,8 +618,8 @@ describe('effective quota', () => {
|
||||
.insert(orgQuotaEntitlements)
|
||||
.values(entitlement(orgId, 'traffic', 'free-traffic-plan', 1000, 'active', now, 'Free'))
|
||||
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 401, now)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 600, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 401, now)).resolves.toBe(false)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(600)
|
||||
@@ -654,7 +649,7 @@ describe('effective quota', () => {
|
||||
END
|
||||
`)
|
||||
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 400, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 400, now)).resolves.toBe(true)
|
||||
|
||||
const rows = await db.select().from(orgQuotas).where(eq(orgQuotas.orgId, orgId))
|
||||
expect(rows[0].trafficUsed).toBe(700)
|
||||
@@ -666,8 +661,8 @@ describe('effective quota', () => {
|
||||
const orgId = nanoid()
|
||||
const now = new Date('2026-05-06T00:00:00Z')
|
||||
|
||||
await expect(hasTrafficQuotaForBytes(db, orgId, 1024, now)).resolves.toBe(true)
|
||||
await expect(consumeTrafficIfQuotaAllows(db, orgId, 1024, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).hasTrafficQuotaForBytes(orgId, 1024, now)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).consumeTrafficIfQuotaAllows(orgId, 1024, now)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('treats zero base storage quota as limited when storage entitlements exist', async () => {
|
||||
@@ -685,8 +680,8 @@ describe('effective quota', () => {
|
||||
})
|
||||
await db.insert(orgQuotaEntitlements).values(entitlement(orgId, 'storage', 'storage-zero-base', 500, 'active', now))
|
||||
|
||||
await expect(hasQuotaForBytes(db, orgId, 100)).resolves.toBe(true)
|
||||
await expect(hasQuotaForBytes(db, orgId, 101)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 100)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 101)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('enforces storage against subscription plan plus extra entitlements', async () => {
|
||||
@@ -710,8 +705,8 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
|
||||
])
|
||||
|
||||
await expect(hasQuotaForBytes(db, orgId, 1600)).resolves.toBe(true)
|
||||
await expect(hasQuotaForBytes(db, orgId, 1601)).resolves.toBe(false)
|
||||
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 1600)).resolves.toBe(true)
|
||||
await expect(createQuotaRepo(db).hasQuotaForBytes(orgId, 1601)).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('atomically enforces storage against the largest active subscription plan plus extra entitlements', async () => {
|
||||
@@ -736,8 +731,12 @@ describe('effective quota', () => {
|
||||
entitlement(orgId, 'storage', 'storage-pack', 500, 'active', now),
|
||||
])
|
||||
|
||||
await expect(incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, 1600, true, now)).resolves.toBe(true)
|
||||
await expect(incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, 1, true, now)).resolves.toBe(false)
|
||||
await expect(
|
||||
createQuotaRepo(db).incrementUsageIfEffectiveQuotaAllows(orgId, storageId, 1600, true, now),
|
||||
).resolves.toBe(true)
|
||||
await expect(
|
||||
createQuotaRepo(db).incrementUsageIfEffectiveQuotaAllows(orgId, storageId, 1, true, now),
|
||||
).resolves.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,42 +1,11 @@
|
||||
import { and, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import { orgQuotaEntitlements, orgQuotas, storages } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import { organization } from '../../db/auth-schema'
|
||||
import { orgQuotaEntitlements, orgQuotas, storages } from '../../db/schema'
|
||||
import { currentTrafficPeriod } from '../../domain/quota'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { CurrentStoragePlan, EffectiveQuota, QuotaRepo } from '../../usecases/ports'
|
||||
|
||||
export interface EffectiveQuota {
|
||||
orgId: string
|
||||
baseQuota: number
|
||||
entitlementQuota: number
|
||||
quota: number
|
||||
used: number
|
||||
baseTrafficQuota: number
|
||||
entitlementTrafficQuota: number
|
||||
trafficQuota: number
|
||||
trafficUsed: number
|
||||
trafficPeriod: string
|
||||
storagePlanName: string | null
|
||||
storageExtraNames: string[]
|
||||
trafficPlanName: string | null
|
||||
trafficExtraNames: string[]
|
||||
currentPlan: CurrentStoragePlan | null
|
||||
}
|
||||
|
||||
export interface CurrentStoragePlan {
|
||||
sourceId: string
|
||||
packageId: string | null
|
||||
name: string
|
||||
storageBytes: number
|
||||
trafficBytes: number
|
||||
trafficOveragePriceCents: number | null
|
||||
expiresAt: string | null
|
||||
subscription: boolean
|
||||
}
|
||||
|
||||
export function currentTrafficPeriod(now = new Date()): string {
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${now.getUTCFullYear()}-${month}`
|
||||
}
|
||||
|
||||
export async function getEffectiveQuota(db: Database, orgId: string, now = new Date()): Promise<EffectiveQuota> {
|
||||
async function getEffectiveQuota(db: Database, orgId: string, now = new Date()): Promise<EffectiveQuota> {
|
||||
// Delegate to the batch path's single in-memory aggregation. It always
|
||||
// returns an entry for every requested orgId (zero-filled when absent).
|
||||
const byOrg = await getEffectiveQuotasByOrg(db, [orgId], now)
|
||||
@@ -46,7 +15,7 @@ export async function getEffectiveQuota(db: Database, orgId: string, now = new D
|
||||
// Batch variant of getEffectiveQuota for list views. Resolves every org with two
|
||||
// queries total (quota rows + active entitlements) instead of ~8 per org, then
|
||||
// aggregates in memory. Returns one entry per requested orgId, even with no rows.
|
||||
export async function getEffectiveQuotasByOrg(
|
||||
async function getEffectiveQuotasByOrg(
|
||||
db: Database,
|
||||
orgIds: string[],
|
||||
now = new Date(),
|
||||
@@ -192,7 +161,7 @@ function extraEntitlementNames(ents: EntitlementRow[], resourceType: 'storage' |
|
||||
// Idempotent: the WHERE clause matches nothing once a period has been reset, so it
|
||||
// is safe to run on a schedule. getEffectiveQuota already normalizes stale periods
|
||||
// in memory, so reads stay correct even between scheduled runs.
|
||||
export async function resetExpiredTrafficQuotas(db: Database, now = new Date()): Promise<void> {
|
||||
async function resetExpiredTrafficQuotas(db: Database, now = new Date()): Promise<void> {
|
||||
const period = currentTrafficPeriod(now)
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
@@ -200,19 +169,14 @@ export async function resetExpiredTrafficQuotas(db: Database, now = new Date()):
|
||||
.where(sql`${orgQuotas.trafficPeriod} != ${period}`)
|
||||
}
|
||||
|
||||
export async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise<boolean> {
|
||||
async function hasQuotaForBytes(db: Database, orgId: string, bytes: number): Promise<boolean> {
|
||||
if (bytes <= 0) return true
|
||||
const quota = await getEffectiveQuota(db, orgId)
|
||||
if (quota.baseQuota === 0 && quota.entitlementQuota === 0) return true
|
||||
return quota.used + bytes <= quota.quota
|
||||
}
|
||||
|
||||
export async function hasTrafficQuotaForBytes(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
bytes: number,
|
||||
now = new Date(),
|
||||
): Promise<boolean> {
|
||||
async function hasTrafficQuotaForBytes(db: Database, orgId: string, bytes: number, now = new Date()): Promise<boolean> {
|
||||
if (bytes <= 0) return true
|
||||
const quota = await getEffectiveQuota(db, orgId, now)
|
||||
if (quota.baseTrafficQuota === 0 && quota.entitlementTrafficQuota === 0) return true
|
||||
@@ -220,7 +184,7 @@ export async function hasTrafficQuotaForBytes(
|
||||
return quota.trafficUsed + bytes <= quota.trafficQuota
|
||||
}
|
||||
|
||||
export async function consumeTrafficIfQuotaAllows(
|
||||
async function consumeTrafficIfQuotaAllows(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
bytes: number,
|
||||
@@ -291,7 +255,7 @@ async function hasActiveTrafficOverage(db: Database, orgId: string, now: Date) {
|
||||
return (trafficPlan?.trafficOveragePriceCents ?? 0) > 0
|
||||
}
|
||||
|
||||
export async function refundTraffic(db: Database, orgId: string, bytes: number, now = new Date()): Promise<void> {
|
||||
async function refundTraffic(db: Database, orgId: string, bytes: number, now = new Date()): Promise<void> {
|
||||
if (bytes <= 0) return
|
||||
const period = currentTrafficPeriod(now)
|
||||
await db
|
||||
@@ -302,7 +266,7 @@ export async function refundTraffic(db: Database, orgId: string, bytes: number,
|
||||
.where(sql`${orgQuotas.orgId} = ${orgId} AND ${orgQuotas.trafficPeriod} = ${period}`)
|
||||
}
|
||||
|
||||
export async function incrementUsageIfEffectiveQuotaAllows(
|
||||
async function incrementUsageIfEffectiveQuotaAllows(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
storageId: string,
|
||||
@@ -483,3 +447,31 @@ function entitlementName(metadata: string | null) {
|
||||
function isSubscriptionSourceId(sourceId: string) {
|
||||
return sourceId.startsWith('stripe_subscription:')
|
||||
}
|
||||
|
||||
async function listOrgQuotaOverview(db: Database) {
|
||||
return db
|
||||
.select({
|
||||
id: orgQuotas.id,
|
||||
orgId: orgQuotas.orgId,
|
||||
orgName: organization.name,
|
||||
orgMetadata: organization.metadata,
|
||||
})
|
||||
.from(orgQuotas)
|
||||
.innerJoin(organization, eq(organization.id, orgQuotas.orgId))
|
||||
.orderBy(organization.name)
|
||||
}
|
||||
|
||||
export function createQuotaRepo(db: Database): QuotaRepo {
|
||||
return {
|
||||
listOrgQuotaOverview: () => listOrgQuotaOverview(db),
|
||||
getEffectiveQuota: (orgId, now) => getEffectiveQuota(db, orgId, now),
|
||||
getEffectiveQuotasByOrg: (orgIds, now) => getEffectiveQuotasByOrg(db, orgIds, now),
|
||||
resetExpiredTrafficQuotas: (now) => resetExpiredTrafficQuotas(db, now),
|
||||
hasQuotaForBytes: (orgId, bytes) => hasQuotaForBytes(db, orgId, bytes),
|
||||
hasTrafficQuotaForBytes: (orgId, bytes, now) => hasTrafficQuotaForBytes(db, orgId, bytes, now),
|
||||
consumeTrafficIfQuotaAllows: (orgId, bytes, now) => consumeTrafficIfQuotaAllows(db, orgId, bytes, now),
|
||||
refundTraffic: (orgId, bytes, now) => refundTraffic(db, orgId, bytes, now),
|
||||
incrementUsageIfEffectiveQuotaAllows: (orgId, storageId, bytes, teamQuotaEnabled, now) =>
|
||||
incrementUsageIfEffectiveQuotaAllows(db, orgId, storageId, bytes, teamQuotaEnabled, now),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { asc, eq, inArray } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { remoteDownloadUsageReports } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
InsertRemoteDownloadUsageReportInput,
|
||||
RemoteDownloadUsageRepo,
|
||||
RemoteDownloadUsageReportRecord,
|
||||
RemoteDownloadUsageStatus,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
function toRecord(row: typeof remoteDownloadUsageReports.$inferSelect): RemoteDownloadUsageReportRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
orgId: row.orgId,
|
||||
downloaderId: row.downloaderId,
|
||||
taskId: row.taskId,
|
||||
eventId: row.eventId,
|
||||
unitIndex: row.unitIndex,
|
||||
unitBytes: row.unitBytes,
|
||||
creditsPerUnit: row.creditsPerUnit,
|
||||
status: row.status as RemoteDownloadUsageStatus,
|
||||
error: row.error,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
export function createRemoteDownloadUsageRepo(db: Database): RemoteDownloadUsageRepo {
|
||||
return {
|
||||
async findByEventId(eventId) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(remoteDownloadUsageReports)
|
||||
.where(eq(remoteDownloadUsageReports.eventId, eventId))
|
||||
.limit(1)
|
||||
return rows[0] ? toRecord(rows[0]) : undefined
|
||||
},
|
||||
|
||||
async insert(input: InsertRemoteDownloadUsageReportInput) {
|
||||
await db.insert(remoteDownloadUsageReports).values({
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
downloaderId: input.downloaderId,
|
||||
taskId: input.taskId,
|
||||
eventId: input.eventId,
|
||||
unitIndex: input.unitIndex,
|
||||
unitBytes: input.unitBytes,
|
||||
creditsPerUnit: input.creditsPerUnit,
|
||||
status: 'pending',
|
||||
error: null,
|
||||
createdAt: input.now,
|
||||
updatedAt: input.now,
|
||||
})
|
||||
},
|
||||
|
||||
async updateStatus(eventId, status, error, now) {
|
||||
await db
|
||||
.update(remoteDownloadUsageReports)
|
||||
.set({ status, error, updatedAt: now })
|
||||
.where(eq(remoteDownloadUsageReports.eventId, eventId))
|
||||
},
|
||||
|
||||
async listPending(limit) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(remoteDownloadUsageReports)
|
||||
.where(inArray(remoteDownloadUsageReports.status, ['pending', 'failed']))
|
||||
.orderBy(asc(remoteDownloadUsageReports.createdAt))
|
||||
.limit(limit)
|
||||
return rows.map(toRecord)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { user } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { ShareNotificationRepo } from '../../usecases/ports'
|
||||
|
||||
export function createShareNotificationRepo(db: Database): ShareNotificationRepo {
|
||||
return {
|
||||
async getUserEmail(userId) {
|
||||
const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1)
|
||||
return rows[0]?.email ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
import { createCloudflarePlatform } from '../platform/cloudflare'
|
||||
import {
|
||||
cascadeDeleteByMatter,
|
||||
createShare,
|
||||
incrementDownloadsAtomic,
|
||||
resolveShareByToken,
|
||||
revokeShareByToken,
|
||||
} from './share'
|
||||
import { DirType } from '../../../shared/constants'
|
||||
import type { CreateShareInput } from '../../../shared/schemas/share'
|
||||
import { matters } from '../../db/schema'
|
||||
import { createCloudflarePlatform } from '../../platform/cloudflare'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { createShareRepo } from './share'
|
||||
|
||||
const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input)
|
||||
const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token)
|
||||
const incrementDownloadsAtomic = (db: Database, shareId: string) =>
|
||||
createShareRepo(db).incrementDownloadsAtomic(shareId)
|
||||
const revokeShareByToken = (db: Database, token: string, creatorId: string) =>
|
||||
createShareRepo(db).revokeByToken(token, creatorId)
|
||||
const cascadeDeleteByMatter = (db: Database, matterId: string) => createShareRepo(db).cascadeDeleteByMatter(matterId)
|
||||
|
||||
function buildDb() {
|
||||
return createCloudflarePlatform(env).db
|
||||
+22
-14
@@ -1,20 +1,28 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import {
|
||||
cascadeDeleteByMatter,
|
||||
createShare,
|
||||
incrementDownloadsAtomic,
|
||||
incrementViews,
|
||||
isAccessibleByUser,
|
||||
listShareRecipientUserIds,
|
||||
resolveShareByToken,
|
||||
revokeShareByToken,
|
||||
verifyPassword,
|
||||
} from './share.js'
|
||||
import { DirType } from '../../../shared/constants'
|
||||
import type { CreateShareInput } from '../../../shared/schemas/share'
|
||||
import { matters } from '../../db/schema'
|
||||
import { isAccessibleByUser } from '../../domain/share'
|
||||
import { verifyPassword as verifyPasswordHash } from '../../lib/password'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createShareRepo } from './share.js'
|
||||
|
||||
// Thin adapters so the existing call sites stay readable: the repo is the unit
|
||||
// under test, constructed per call from the test db.
|
||||
const createShare = (db: Database, input: CreateShareInput) => createShareRepo(db).create(input)
|
||||
const resolveShareByToken = (db: Database, token: string) => createShareRepo(db).resolveByToken(token)
|
||||
const incrementViews = (db: Database, shareId: string) => createShareRepo(db).incrementViews(shareId)
|
||||
const incrementDownloadsAtomic = (db: Database, shareId: string) =>
|
||||
createShareRepo(db).incrementDownloadsAtomic(shareId)
|
||||
const revokeShareByToken = (db: Database, token: string, creatorId: string) =>
|
||||
createShareRepo(db).revokeByToken(token, creatorId)
|
||||
const listShareRecipientUserIds = (db: Database, shareId: string) => createShareRepo(db).listRecipientUserIds(shareId)
|
||||
const cascadeDeleteByMatter = (db: Database, matterId: string) => createShareRepo(db).cascadeDeleteByMatter(matterId)
|
||||
const verifyPassword = (share: { passwordHash: string | null }, plaintext: string): boolean =>
|
||||
share.passwordHash ? verifyPasswordHash(share.passwordHash, plaintext) : false
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import type { CreateShareInput } from '@shared/schemas/share'
|
||||
import { and, count, desc, eq, inArray, isNotNull, isNull, like, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { user } from '../../db/auth-schema'
|
||||
import { matters, shareRecipients, shares } from '../../db/schema'
|
||||
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
|
||||
import { hashPassword } from '../../lib/password'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
CreateShareError,
|
||||
type Matter,
|
||||
type ShareListItem,
|
||||
type ShareRecord,
|
||||
type ShareRepo,
|
||||
type ShareResolution,
|
||||
} from '../../usecases/ports'
|
||||
import { createQuotaRepo } from './quota'
|
||||
|
||||
function buildPath(parent: string, name: string): string {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
// Escape LIKE wildcards so user-controlled folder names don't act as patterns.
|
||||
function escapeLike(s: string): string {
|
||||
return s.replace(/[\\%_]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function createShareRepo(db: Database): ShareRepo {
|
||||
const quota = createQuotaRepo(db)
|
||||
|
||||
return {
|
||||
async create(input: CreateShareInput): Promise<ShareRecord> {
|
||||
if (input.kind === 'direct' && input.password) throw new CreateShareError('DIRECT_NO_PASSWORD')
|
||||
if (input.kind === 'direct' && input.recipients && input.recipients.length > 0)
|
||||
throw new CreateShareError('DIRECT_NO_RECIPIENTS')
|
||||
|
||||
const matter = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.id, input.matterId), eq(matters.orgId, input.orgId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
|
||||
if (!matter) throw new CreateShareError('MATTER_NOT_FOUND')
|
||||
if (input.kind === 'direct' && matter.dirtype !== DirType.FILE) throw new CreateShareError('DIRECT_NO_FOLDER')
|
||||
|
||||
const now = new Date()
|
||||
const token = input.kind === 'direct' ? `ds_${nanoid(10)}` : nanoid(10)
|
||||
const share: ShareRecord = {
|
||||
id: nanoid(),
|
||||
token,
|
||||
kind: input.kind,
|
||||
matterId: input.matterId,
|
||||
orgId: input.orgId,
|
||||
creatorId: input.creatorId,
|
||||
passwordHash: input.password ? hashPassword(input.password) : null,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
downloadLimit: input.downloadLimit ?? null,
|
||||
views: 0,
|
||||
downloads: 0,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
}
|
||||
|
||||
const queries: AtomicQuery[] = [db.insert(shares).values(share)]
|
||||
if (input.recipients && input.recipients.length > 0) {
|
||||
const recipientRows = input.recipients.map((r) => ({
|
||||
id: nanoid(),
|
||||
shareId: share.id,
|
||||
recipientUserId: r.recipientUserId ?? null,
|
||||
recipientEmail: r.recipientEmail ?? null,
|
||||
createdAt: now,
|
||||
}))
|
||||
queries.push(db.insert(shareRecipients).values(recipientRows))
|
||||
}
|
||||
|
||||
await executeWriteTransaction(db, queries)
|
||||
return share
|
||||
},
|
||||
|
||||
async resolveByToken(token: string): Promise<ShareResolution> {
|
||||
const rows = await db
|
||||
.select({ share: shares, matter: matters })
|
||||
.from(shares)
|
||||
.innerJoin(matters, eq(shares.matterId, matters.id))
|
||||
.where(eq(shares.token, token))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return { status: 'not_found' }
|
||||
if (row.share.status === 'revoked') return { status: 'revoked' }
|
||||
if (row.matter.status === 'trashed') return { status: 'matter_trashed' }
|
||||
|
||||
const recipients = await db.select().from(shareRecipients).where(eq(shareRecipients.shareId, row.share.id))
|
||||
|
||||
return { status: 'ok', share: row.share, matter: row.matter, recipients }
|
||||
},
|
||||
|
||||
async incrementViews(shareId: string): Promise<void> {
|
||||
await db
|
||||
.update(shares)
|
||||
.set({ views: sql`${shares.views} + 1` })
|
||||
.where(eq(shares.id, shareId))
|
||||
},
|
||||
|
||||
async hasDownloadsAvailable(shareId: string): Promise<boolean> {
|
||||
const nowSecs = Math.floor(Date.now() / 1000)
|
||||
const rows = await db
|
||||
.select({ id: shares.id })
|
||||
.from(shares)
|
||||
.where(
|
||||
and(
|
||||
eq(shares.id, shareId),
|
||||
eq(shares.status, 'active'),
|
||||
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
|
||||
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
return rows.length === 1
|
||||
},
|
||||
|
||||
async incrementDownloadsAtomic(shareId: string): Promise<{ ok: boolean; downloads: number }> {
|
||||
const nowSecs = Math.floor(Date.now() / 1000)
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ downloads: sql`${shares.downloads} + 1` })
|
||||
.where(
|
||||
and(
|
||||
eq(shares.id, shareId),
|
||||
eq(shares.status, 'active'),
|
||||
or(isNull(shares.downloadLimit), sql`${shares.downloads} < ${shares.downloadLimit}`),
|
||||
or(isNull(shares.expiresAt), sql`${shares.expiresAt} > ${nowSecs}`),
|
||||
),
|
||||
)
|
||||
.returning({ downloads: shares.downloads })
|
||||
|
||||
if (result.length === 1) {
|
||||
return { ok: true, downloads: result[0].downloads }
|
||||
}
|
||||
|
||||
const current = await db.select({ downloads: shares.downloads }).from(shares).where(eq(shares.id, shareId))
|
||||
if (!current[0]) throw new Error('SHARE_NOT_FOUND')
|
||||
return { ok: false, downloads: current[0].downloads }
|
||||
},
|
||||
|
||||
async decrementDownloads(shareId: string): Promise<void> {
|
||||
await db
|
||||
.update(shares)
|
||||
.set({ downloads: sql`CASE WHEN ${shares.downloads} > 0 THEN ${shares.downloads} - 1 ELSE 0 END` })
|
||||
.where(eq(shares.id, shareId))
|
||||
},
|
||||
|
||||
async listRecipientUserIds(shareId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ userId: shareRecipients.recipientUserId })
|
||||
.from(shareRecipients)
|
||||
.where(and(eq(shareRecipients.shareId, shareId), isNotNull(shareRecipients.recipientUserId)))
|
||||
|
||||
return rows.map((r) => r.userId as string)
|
||||
},
|
||||
|
||||
async cascadeDeleteByMatter(matterId: string): Promise<void> {
|
||||
const shareRows = await db.select({ id: shares.id }).from(shares).where(eq(shares.matterId, matterId))
|
||||
if (shareRows.length === 0) return
|
||||
|
||||
const shareIds = shareRows.map((r) => r.id)
|
||||
await executeWriteTransaction(db, [
|
||||
db.delete(shareRecipients).where(inArray(shareRecipients.shareId, shareIds)),
|
||||
db.delete(shares).where(inArray(shares.id, shareIds)),
|
||||
])
|
||||
},
|
||||
|
||||
async getCreatorByToken(token: string): Promise<string | null> {
|
||||
const rows = await db.select({ creatorId: shares.creatorId }).from(shares).where(eq(shares.token, token))
|
||||
return rows[0]?.creatorId ?? null
|
||||
},
|
||||
|
||||
async revokeByToken(token: string, creatorId: string): Promise<boolean> {
|
||||
const result = await db
|
||||
.update(shares)
|
||||
.set({ status: 'revoked' })
|
||||
.where(and(eq(shares.token, token), eq(shares.creatorId, creatorId)))
|
||||
.returning({ id: shares.id })
|
||||
|
||||
return result.length > 0
|
||||
},
|
||||
|
||||
async listForApi(
|
||||
creatorId: string,
|
||||
opts: { page: number; pageSize: number; status?: string },
|
||||
): Promise<{ items: ShareListItem[]; total: number }> {
|
||||
const conditions = [eq(shares.creatorId, creatorId)]
|
||||
if (opts.status) conditions.push(eq(shares.status, opts.status))
|
||||
const where = and(...conditions)
|
||||
|
||||
const [countRow] = await db.select({ count: count() }).from(shares).where(where)
|
||||
const total = countRow?.count ?? 0
|
||||
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
const rows = await db
|
||||
.select({
|
||||
id: shares.id,
|
||||
token: shares.token,
|
||||
kind: shares.kind,
|
||||
matterId: shares.matterId,
|
||||
orgId: shares.orgId,
|
||||
creatorId: shares.creatorId,
|
||||
expiresAt: shares.expiresAt,
|
||||
downloadLimit: shares.downloadLimit,
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
matterDirtype: matters.dirtype,
|
||||
recipientCount: count(shareRecipients.id),
|
||||
})
|
||||
.from(shares)
|
||||
.leftJoin(matters, eq(shares.matterId, matters.id))
|
||||
.leftJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
|
||||
.where(where)
|
||||
.groupBy(shares.id)
|
||||
.orderBy(desc(shares.createdAt))
|
||||
.limit(opts.pageSize)
|
||||
.offset(offset)
|
||||
|
||||
const items: ShareListItem[] = rows.map(
|
||||
({ matterName, matterType, matterDirtype, recipientCount, ...share }) => ({
|
||||
...share,
|
||||
matter: { name: matterName ?? '', type: matterType ?? '', dirtype: matterDirtype ?? 0 },
|
||||
recipientCount,
|
||||
}),
|
||||
)
|
||||
|
||||
return { items, total }
|
||||
},
|
||||
|
||||
async listReceivedForApi(
|
||||
userId: string,
|
||||
userEmail: string | null,
|
||||
opts: { page: number; pageSize: number },
|
||||
): Promise<{ items: ShareListItem[]; total: number }> {
|
||||
const recipientMatch = userEmail
|
||||
? or(eq(shareRecipients.recipientUserId, userId), eq(shareRecipients.recipientEmail, userEmail))
|
||||
: eq(shareRecipients.recipientUserId, userId)
|
||||
const where = and(eq(shares.status, 'active'), recipientMatch)
|
||||
|
||||
const [countRow] = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${shares.id})` })
|
||||
.from(shares)
|
||||
.innerJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
|
||||
.where(where)
|
||||
const total = countRow?.count ?? 0
|
||||
|
||||
const offset = (opts.page - 1) * opts.pageSize
|
||||
const rows = await db
|
||||
.select({
|
||||
id: shares.id,
|
||||
token: shares.token,
|
||||
kind: shares.kind,
|
||||
matterId: shares.matterId,
|
||||
orgId: shares.orgId,
|
||||
creatorId: shares.creatorId,
|
||||
expiresAt: shares.expiresAt,
|
||||
downloadLimit: shares.downloadLimit,
|
||||
views: shares.views,
|
||||
downloads: shares.downloads,
|
||||
status: shares.status,
|
||||
createdAt: shares.createdAt,
|
||||
matterName: matters.name,
|
||||
matterType: matters.type,
|
||||
matterDirtype: matters.dirtype,
|
||||
creatorName: sql<string | null>`(SELECT name FROM user WHERE user.id = ${shares.creatorId})`,
|
||||
})
|
||||
.from(shares)
|
||||
.innerJoin(shareRecipients, eq(shareRecipients.shareId, shares.id))
|
||||
.leftJoin(matters, eq(shares.matterId, matters.id))
|
||||
.where(where)
|
||||
.groupBy(shares.id)
|
||||
.orderBy(desc(shares.createdAt))
|
||||
.limit(opts.pageSize)
|
||||
.offset(offset)
|
||||
|
||||
const items: ShareListItem[] = rows.map(({ matterName, matterType, matterDirtype, creatorName, ...share }) => ({
|
||||
...share,
|
||||
matter: { name: matterName ?? '', type: matterType ?? '', dirtype: matterDirtype ?? 0 },
|
||||
recipientCount: 0,
|
||||
creatorName: creatorName ?? undefined,
|
||||
}))
|
||||
|
||||
return { items, total }
|
||||
},
|
||||
|
||||
async computeSourceBytes(matter: Matter): Promise<number> {
|
||||
if (matter.dirtype === DirType.FILE) return matter.size ?? 0
|
||||
|
||||
const folderPath = buildPath(matter.parent, matter.name)
|
||||
const rows = await db
|
||||
.select({ size: matters.size })
|
||||
.from(matters)
|
||||
.where(
|
||||
and(
|
||||
eq(matters.orgId, matter.orgId),
|
||||
eq(matters.status, 'active'),
|
||||
eq(matters.dirtype, DirType.FILE),
|
||||
or(eq(matters.parent, folderPath), like(matters.parent, `${folderPath}/%`)),
|
||||
),
|
||||
)
|
||||
return rows.reduce((acc, r) => acc + (r.size ?? 0), 0)
|
||||
},
|
||||
|
||||
async listDirectActiveChildren(orgId: string, folderPath: string): Promise<Matter[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), eq(matters.parent, folderPath), eq(matters.status, 'active')))
|
||||
},
|
||||
|
||||
hasQuotaForBytes(orgId: string, bytes: number): Promise<boolean> {
|
||||
return quota.hasQuotaForBytes(orgId, bytes)
|
||||
},
|
||||
|
||||
async getCreatorName(creatorId: string): Promise<string | null> {
|
||||
const rows = await db.select({ name: user.name }).from(user).where(eq(user.id, creatorId)).limit(1)
|
||||
return rows[0]?.name ?? null
|
||||
},
|
||||
|
||||
async getUserEmail(userId: string): Promise<string | null> {
|
||||
const rows = await db.select({ email: user.email }).from(user).where(eq(user.id, userId)).limit(1)
|
||||
return rows[0]?.email ?? null
|
||||
},
|
||||
|
||||
async getMatterName(matterId: string): Promise<string | null> {
|
||||
const rows = await db.select({ name: matters.name }).from(matters).where(eq(matters.id, matterId)).limit(1)
|
||||
return rows[0]?.name ?? null
|
||||
},
|
||||
|
||||
async findShareChildMatter(
|
||||
rootMatter: { id: string; orgId: string; parent: string; name: string },
|
||||
childId: string,
|
||||
): Promise<Matter | null> {
|
||||
const root = buildPath(rootMatter.parent, rootMatter.name)
|
||||
const likePattern = `${escapeLike(root)}/%`
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(
|
||||
and(
|
||||
eq(matters.id, childId),
|
||||
eq(matters.orgId, rootMatter.orgId),
|
||||
eq(matters.status, 'active'),
|
||||
or(eq(matters.parent, root), sql`${matters.parent} LIKE ${likePattern} ESCAPE '\\'`),
|
||||
),
|
||||
)
|
||||
return rows[0] ?? null
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { DEFAULT_SITE_NAME } from '@shared/constants'
|
||||
import type { SiteInvitation } from '@shared/types'
|
||||
import { and, count, desc, eq, gt, isNull } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DEFAULT_SITE_NAME } from '../../shared/constants'
|
||||
import type { SiteInvitation } from '../../shared/types'
|
||||
import * as authSchema from '../db/auth-schema'
|
||||
import { siteInvitations, systemOptions } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import * as authSchema from '../../db/auth-schema'
|
||||
import { siteInvitations, systemOptions } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { SiteInvitationRepo } from '../../usecases/ports'
|
||||
|
||||
const SITE_INVITE_EXPIRY_MS = 1000 * 60 * 60 * 24 * 7
|
||||
|
||||
@@ -37,12 +38,12 @@ function mapInvitation(row: typeof siteInvitations.$inferSelect & { invitedByNam
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSiteName(db: Database): Promise<string> {
|
||||
async function getSiteName(db: Database): Promise<string> {
|
||||
const rows = await db.select().from(systemOptions).where(eq(systemOptions.key, 'site_name')).limit(1)
|
||||
return rows[0]?.value?.trim() || DEFAULT_SITE_NAME
|
||||
}
|
||||
|
||||
export async function listSiteInvitations(
|
||||
async function listSiteInvitations(
|
||||
db: Database,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
@@ -77,11 +78,7 @@ export async function listSiteInvitations(
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSiteInvitation(
|
||||
db: Database,
|
||||
adminUserId: string,
|
||||
rawEmail: string,
|
||||
): Promise<SiteInvitation> {
|
||||
async function createSiteInvitation(db: Database, adminUserId: string, rawEmail: string): Promise<SiteInvitation> {
|
||||
const email = normalizeEmail(rawEmail)
|
||||
const now = new Date()
|
||||
|
||||
@@ -138,7 +135,7 @@ export async function createSiteInvitation(
|
||||
})
|
||||
}
|
||||
|
||||
export async function resendSiteInvitation(
|
||||
async function resendSiteInvitation(
|
||||
db: Database,
|
||||
invitationId: string,
|
||||
): Promise<SiteInvitation | 'not_found' | 'already_accepted' | 'already_revoked'> {
|
||||
@@ -181,7 +178,7 @@ export async function resendSiteInvitation(
|
||||
return mapInvitation(updated)
|
||||
}
|
||||
|
||||
export async function revokeSiteInvitation(
|
||||
async function revokeSiteInvitation(
|
||||
db: Database,
|
||||
invitationId: string,
|
||||
adminUserId: string,
|
||||
@@ -203,7 +200,7 @@ export async function revokeSiteInvitation(
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
export async function getSiteInvitationByToken(db: Database, token: string): Promise<SiteInvitation | null> {
|
||||
async function getSiteInvitationByToken(db: Database, token: string): Promise<SiteInvitation | null> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: siteInvitations.id,
|
||||
@@ -226,7 +223,7 @@ export async function getSiteInvitationByToken(db: Database, token: string): Pro
|
||||
return row ? mapInvitation(row) : null
|
||||
}
|
||||
|
||||
export async function validateSiteInvitation(
|
||||
async function validateSiteInvitation(
|
||||
db: Database,
|
||||
token: string,
|
||||
rawEmail: string,
|
||||
@@ -241,7 +238,7 @@ export async function validateSiteInvitation(
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export async function acceptSiteInvitation(
|
||||
async function acceptSiteInvitation(
|
||||
db: Database,
|
||||
token: string,
|
||||
rawEmail: string,
|
||||
@@ -282,3 +279,16 @@ export async function acceptSiteInvitation(
|
||||
if (updated?.acceptedBy) return 'accepted'
|
||||
return 'accepted'
|
||||
}
|
||||
|
||||
export function createSiteInvitationRepo(db: Database): SiteInvitationRepo {
|
||||
return {
|
||||
getSiteName: () => getSiteName(db),
|
||||
listSiteInvitations: (page, pageSize) => listSiteInvitations(db, page, pageSize),
|
||||
createSiteInvitation: (adminUserId, rawEmail) => createSiteInvitation(db, adminUserId, rawEmail),
|
||||
resendSiteInvitation: (invitationId) => resendSiteInvitation(db, invitationId),
|
||||
revokeSiteInvitation: (invitationId, adminUserId) => revokeSiteInvitation(db, invitationId, adminUserId),
|
||||
getSiteInvitationByToken: (token) => getSiteInvitationByToken(db, token),
|
||||
validateSiteInvitation: (token, rawEmail) => validateSiteInvitation(db, token, rawEmail),
|
||||
acceptSiteInvitation: (token, rawEmail, userId) => acceptSiteInvitation(db, token, rawEmail, userId),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { DirType } from '@shared/constants'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { imageHostings, matters, orgQuotas, storages } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { StorageUsageRepo } from '../../usecases/ports'
|
||||
|
||||
export function createStorageUsageRepo(db: Database): StorageUsageRepo {
|
||||
return {
|
||||
async rollbackReservations(reservations) {
|
||||
const bytesByStorage = new Map<string, number>()
|
||||
const bytesByOrg = new Map<string, number>()
|
||||
|
||||
for (const reservation of reservations) {
|
||||
if (!reservation || reservation.bytes <= 0) continue
|
||||
bytesByStorage.set(reservation.storageId, (bytesByStorage.get(reservation.storageId) ?? 0) + reservation.bytes)
|
||||
bytesByOrg.set(reservation.orgId, (bytesByOrg.get(reservation.orgId) ?? 0) + reservation.bytes)
|
||||
}
|
||||
|
||||
for (const [storageId, bytes] of bytesByStorage) {
|
||||
await db
|
||||
.update(storages)
|
||||
.set({ used: sql`MAX(0, ${storages.used} - ${bytes})` })
|
||||
.where(eq(storages.id, storageId))
|
||||
}
|
||||
|
||||
for (const [orgId, bytes] of bytesByOrg) {
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
.set({ used: sql`MAX(0, ${orgQuotas.used} - ${bytes})` })
|
||||
.where(eq(orgQuotas.orgId, orgId))
|
||||
}
|
||||
},
|
||||
|
||||
async reconcile(orgId, storageIds = []) {
|
||||
await db
|
||||
.update(orgQuotas)
|
||||
.set({
|
||||
used: sql`COALESCE((
|
||||
SELECT SUM(${matters.size})
|
||||
FROM ${matters}
|
||||
WHERE ${matters.orgId} = ${orgId}
|
||||
AND ${matters.dirtype} = ${DirType.FILE}
|
||||
AND ${matters.status} IN ('active', 'trashed')
|
||||
), 0) + COALESCE((
|
||||
SELECT SUM(${imageHostings.size})
|
||||
FROM ${imageHostings}
|
||||
WHERE ${imageHostings.orgId} = ${orgId}
|
||||
AND ${imageHostings.status} = 'active'
|
||||
), 0)`,
|
||||
})
|
||||
.where(eq(orgQuotas.orgId, orgId))
|
||||
|
||||
for (const storageId of new Set(storageIds)) {
|
||||
await db
|
||||
.update(storages)
|
||||
.set({
|
||||
used: sql`COALESCE((
|
||||
SELECT SUM(${matters.size})
|
||||
FROM ${matters}
|
||||
WHERE ${matters.storageId} = ${storageId}
|
||||
AND ${matters.dirtype} = ${DirType.FILE}
|
||||
AND ${matters.status} IN ('active', 'trashed')
|
||||
), 0) + COALESCE((
|
||||
SELECT SUM(${imageHostings.size})
|
||||
FROM ${imageHostings}
|
||||
WHERE ${imageHostings.storageId} = ${storageId}
|
||||
AND ${imageHostings.status} = 'active'
|
||||
), 0)`,
|
||||
})
|
||||
.where(eq(storages.id, storageId))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
+34
-34
@@ -1,12 +1,12 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { createStorage, deleteStorage, getStorage, listStorages, selectStorage, updateStorage } from './storage.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createStorageRepo } from './storage.js'
|
||||
|
||||
describe('createStorage', () => {
|
||||
it('sets filePath to empty string regardless of input', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -21,7 +21,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('sets customHost to empty string when not provided', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -36,7 +36,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('uses provided customHost when given', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -52,7 +52,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('sets capacity to 0 when not provided', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -67,7 +67,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('uses provided capacity when given', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -82,7 +82,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('initialises used to 0 and status to active', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await createStorage(db, {
|
||||
const result = await createStorageRepo(db).create({
|
||||
title: 'My Storage',
|
||||
mode: 'public',
|
||||
bucket: 'my-bucket',
|
||||
@@ -98,7 +98,7 @@ describe('createStorage', () => {
|
||||
|
||||
it('persists the created row to the database', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorage(db, {
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Persisted',
|
||||
mode: 'private',
|
||||
bucket: 'my-bucket',
|
||||
@@ -108,7 +108,7 @@ describe('createStorage', () => {
|
||||
secretKey: 'SECRET',
|
||||
capacity: 0,
|
||||
})
|
||||
const fetched = await getStorage(db, created.id)
|
||||
const fetched = await createStorageRepo(db).get(created.id)
|
||||
expect(fetched?.id).toBe(created.id)
|
||||
expect(fetched?.title).toBe('Persisted')
|
||||
})
|
||||
@@ -116,7 +116,7 @@ describe('createStorage', () => {
|
||||
|
||||
describe('updateStorage', () => {
|
||||
async function seed(db: Awaited<ReturnType<typeof createTestApp>>['db']) {
|
||||
return createStorage(db, {
|
||||
return createStorageRepo(db).create({
|
||||
title: 'Original',
|
||||
mode: 'private',
|
||||
bucket: 'original-bucket',
|
||||
@@ -131,14 +131,14 @@ describe('updateStorage', () => {
|
||||
|
||||
it('returns null when storage does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await updateStorage(db, 'nonexistent', { title: 'New' })
|
||||
const result = await createStorageRepo(db).update('nonexistent', { title: 'New' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps existing values for fields not included in update', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await updateStorage(db, created.id, { title: 'Changed' })
|
||||
const updated = await createStorageRepo(db).update(created.id, { title: 'Changed' })
|
||||
expect(updated?.bucket).toBe('original-bucket')
|
||||
expect(updated?.region).toBe('us-east-1')
|
||||
expect(updated?.accessKey).toBe('AKID')
|
||||
@@ -150,7 +150,7 @@ describe('updateStorage', () => {
|
||||
it('applies all provided optional fields', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await updateStorage(db, created.id, {
|
||||
const updated = await createStorageRepo(db).update(created.id, {
|
||||
title: 'Updated',
|
||||
mode: 'public',
|
||||
bucket: 'new-bucket',
|
||||
@@ -177,7 +177,7 @@ describe('updateStorage', () => {
|
||||
it('updates only status leaving all other fields intact', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seed(db)
|
||||
const updated = await updateStorage(db, created.id, { status: 'disabled' })
|
||||
const updated = await createStorageRepo(db).update(created.id, { status: 'disabled' })
|
||||
expect(updated?.status).toBe('disabled')
|
||||
expect(updated?.title).toBe('Original')
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe('updateStorage', () => {
|
||||
const created = await seed(db)
|
||||
const before = created.updatedAt.getTime()
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
const updated = await updateStorage(db, created.id, { title: 'New Title' })
|
||||
const updated = await createStorageRepo(db).update(created.id, { title: 'New Title' })
|
||||
expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual(before)
|
||||
})
|
||||
})
|
||||
@@ -195,13 +195,13 @@ describe('updateStorage', () => {
|
||||
describe('listStorages', () => {
|
||||
it('returns empty items and zero total when no storages exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await listStorages(db)
|
||||
const result = await createStorageRepo(db).list()
|
||||
expect(result).toEqual({ items: [], total: 0 })
|
||||
})
|
||||
|
||||
it('returns all storages ordered by createdAt ascending', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await createStorage(db, {
|
||||
await createStorageRepo(db).create({
|
||||
title: 'First',
|
||||
mode: 'private',
|
||||
bucket: 'b1',
|
||||
@@ -211,7 +211,7 @@ describe('listStorages', () => {
|
||||
secretKey: 'S1',
|
||||
capacity: 0,
|
||||
})
|
||||
await createStorage(db, {
|
||||
await createStorageRepo(db).create({
|
||||
title: 'Second',
|
||||
mode: 'public',
|
||||
bucket: 'b2',
|
||||
@@ -221,7 +221,7 @@ describe('listStorages', () => {
|
||||
secretKey: 'S2',
|
||||
capacity: 0,
|
||||
})
|
||||
const result = await listStorages(db)
|
||||
const result = await createStorageRepo(db).list()
|
||||
expect(result.total).toBe(2)
|
||||
expect(result.items).toHaveLength(2)
|
||||
})
|
||||
@@ -230,13 +230,13 @@ describe('listStorages', () => {
|
||||
describe('getStorage', () => {
|
||||
it('returns null when storage does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await getStorage(db, 'nonexistent')
|
||||
const result = await createStorageRepo(db).get('nonexistent')
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the storage when it exists', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorage(db, {
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Findable',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
@@ -246,7 +246,7 @@ describe('getStorage', () => {
|
||||
secretKey: 'S',
|
||||
capacity: 0,
|
||||
})
|
||||
const found = await getStorage(db, created.id)
|
||||
const found = await createStorageRepo(db).get(created.id)
|
||||
expect(found?.id).toBe(created.id)
|
||||
})
|
||||
})
|
||||
@@ -257,7 +257,7 @@ describe('selectStorage', () => {
|
||||
mode: 'private' | 'public',
|
||||
opts: { capacity?: number; used?: number; status?: string } = {},
|
||||
) {
|
||||
return createStorage(db, {
|
||||
return createStorageRepo(db).create({
|
||||
title: 'Seed',
|
||||
mode,
|
||||
bucket: 'b',
|
||||
@@ -272,39 +272,39 @@ describe('selectStorage', () => {
|
||||
it('returns an active private storage with unlimited capacity', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seedActive(db, 'private')
|
||||
const found = await selectStorage(db, 'private')
|
||||
const found = await createStorageRepo(db).select('private')
|
||||
expect(found.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('returns an active public storage when requested', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await seedActive(db, 'public')
|
||||
const found = await selectStorage(db, 'public')
|
||||
const found = await createStorageRepo(db).select('public')
|
||||
expect(found.id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('throws when no active storage exists for the requested mode', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
|
||||
it('throws when storage is present but mode does not match', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await seedActive(db, 'public')
|
||||
await expect(selectStorage(db, 'private')).rejects.toThrow('No available storage')
|
||||
await expect(createStorageRepo(db).select('private')).rejects.toThrow('No available storage')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteStorage', () => {
|
||||
it('returns not_found when storage does not exist', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const result = await deleteStorage(db, 'nonexistent')
|
||||
const result = await createStorageRepo(db).delete('nonexistent')
|
||||
expect(result).toBe('not_found')
|
||||
})
|
||||
|
||||
it('deletes a storage that is not referenced by any matter', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorage(db, {
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'Deletable',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
@@ -314,14 +314,14 @@ describe('deleteStorage', () => {
|
||||
secretKey: 'S',
|
||||
capacity: 0,
|
||||
})
|
||||
const result = await deleteStorage(db, created.id)
|
||||
const result = await createStorageRepo(db).delete(created.id)
|
||||
expect(result).toBe('ok')
|
||||
expect(await getStorage(db, created.id)).toBeNull()
|
||||
expect(await createStorageRepo(db).get(created.id)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns in_use when matters reference the storage', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const created = await createStorage(db, {
|
||||
const created = await createStorageRepo(db).create({
|
||||
title: 'In Use',
|
||||
mode: 'private',
|
||||
bucket: 'b',
|
||||
@@ -336,7 +336,7 @@ describe('deleteStorage', () => {
|
||||
INSERT INTO matters (id, org_id, alias, name, type, storage_id, created_at, updated_at)
|
||||
VALUES ('m-ref', 'org-1', 'alias-ref', 'test.txt', 'text/plain', ${created.id}, ${now}, ${now})
|
||||
`)
|
||||
const result = await deleteStorage(db, created.id)
|
||||
const result = await createStorageRepo(db).delete(created.id)
|
||||
expect(result).toBe('in_use')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { and, asc, count, eq, lt, or } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { matters, storages } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { StorageRecord, StorageRepo } from '../../usecases/ports'
|
||||
|
||||
type StorageRow = typeof storages.$inferSelect
|
||||
|
||||
function toRecord(row: StorageRow): StorageRecord {
|
||||
return row as StorageRecord
|
||||
}
|
||||
|
||||
export function createStorageRepo(db: Database): StorageRepo {
|
||||
async function getRow(id: string): Promise<StorageRow | null> {
|
||||
const rows = await db.select().from(storages).where(eq(storages.id, id))
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(storages).orderBy(asc(storages.createdAt))
|
||||
return { items: rows.map(toRecord), total: rows.length }
|
||||
},
|
||||
|
||||
async get(id) {
|
||||
const row = await getRow(id)
|
||||
return row ? toRecord(row) : null
|
||||
},
|
||||
|
||||
async create(input) {
|
||||
const now = new Date()
|
||||
const row: StorageRow = {
|
||||
id: nanoid(),
|
||||
title: input.title,
|
||||
mode: input.mode,
|
||||
bucket: input.bucket,
|
||||
endpoint: input.endpoint,
|
||||
region: input.region ?? 'auto',
|
||||
accessKey: input.accessKey,
|
||||
secretKey: input.secretKey,
|
||||
filePath: '',
|
||||
customHost: input.customHost ?? '',
|
||||
capacity: input.capacity ?? 0,
|
||||
egressCreditBillingEnabled: input.egressCreditBillingEnabled ?? false,
|
||||
egressCreditUnitBytes: input.egressCreditUnitBytes ?? 104857600,
|
||||
egressCreditPerUnit: input.egressCreditPerUnit ?? 1,
|
||||
used: 0,
|
||||
status: 'active',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(storages).values(row)
|
||||
return toRecord(row)
|
||||
},
|
||||
|
||||
async count() {
|
||||
const rows = await db.select({ count: count() }).from(storages)
|
||||
return rows[0]?.count ?? 0
|
||||
},
|
||||
|
||||
async update(id, input) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return null
|
||||
|
||||
const now = new Date()
|
||||
const updated = {
|
||||
title: input.title ?? existing.title,
|
||||
mode: input.mode ?? existing.mode,
|
||||
bucket: input.bucket ?? existing.bucket,
|
||||
endpoint: input.endpoint ?? existing.endpoint,
|
||||
region: input.region ?? existing.region,
|
||||
accessKey: input.accessKey ?? existing.accessKey,
|
||||
secretKey: input.secretKey ?? existing.secretKey,
|
||||
customHost: input.customHost ?? existing.customHost,
|
||||
capacity: input.capacity ?? existing.capacity,
|
||||
egressCreditBillingEnabled: input.egressCreditBillingEnabled ?? existing.egressCreditBillingEnabled,
|
||||
egressCreditUnitBytes: input.egressCreditUnitBytes ?? existing.egressCreditUnitBytes,
|
||||
egressCreditPerUnit: input.egressCreditPerUnit ?? existing.egressCreditPerUnit,
|
||||
status: input.status ?? existing.status,
|
||||
updatedAt: now,
|
||||
}
|
||||
|
||||
await db.update(storages).set(updated).where(eq(storages.id, id))
|
||||
return toRecord({ ...existing, ...updated })
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const existing = await getRow(id)
|
||||
if (!existing) return 'not_found'
|
||||
|
||||
const refs = await db.select({ count: count() }).from(matters).where(eq(matters.storageId, id))
|
||||
if ((refs[0]?.count ?? 0) > 0) return 'in_use'
|
||||
|
||||
await db.delete(storages).where(eq(storages.id, id))
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
async select(mode) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(storages)
|
||||
.where(
|
||||
and(
|
||||
eq(storages.mode, mode),
|
||||
eq(storages.status, 'active'),
|
||||
or(eq(storages.capacity, 0), lt(storages.used, storages.capacity)),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(storages.createdAt))
|
||||
.limit(1)
|
||||
|
||||
if (rows.length === 0) throw new Error('No available storage')
|
||||
return toRecord(rows[0])
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { eq, like } from 'drizzle-orm'
|
||||
import { systemOptions } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { SystemOption, SystemOptionsRepo } from '../../usecases/ports'
|
||||
|
||||
function toOption(row: { key: string; value: string; public: boolean | null }): SystemOption {
|
||||
return { key: row.key, value: row.value, public: !!row.public }
|
||||
}
|
||||
|
||||
export function createSystemOptionsRepo(db: Database): SystemOptionsRepo {
|
||||
return {
|
||||
async list() {
|
||||
const rows = await db.select().from(systemOptions)
|
||||
return rows.map(toOption)
|
||||
},
|
||||
|
||||
async listPublic() {
|
||||
const rows = await db.select().from(systemOptions).where(eq(systemOptions.public, true))
|
||||
return rows.map(toOption)
|
||||
},
|
||||
|
||||
async get(key) {
|
||||
const rows = await db.select().from(systemOptions).where(eq(systemOptions.key, key)).limit(1)
|
||||
return rows[0] ? toOption(rows[0]) : null
|
||||
},
|
||||
|
||||
async getValue(key) {
|
||||
const rows = await db
|
||||
.select({ value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
.where(eq(systemOptions.key, key))
|
||||
.limit(1)
|
||||
return rows[0]?.value ?? null
|
||||
},
|
||||
|
||||
async listByKeyLike(pattern) {
|
||||
return db
|
||||
.select({ key: systemOptions.key, value: systemOptions.value })
|
||||
.from(systemOptions)
|
||||
.where(like(systemOptions.key, pattern))
|
||||
},
|
||||
|
||||
async set(key, value, isPublic) {
|
||||
await db
|
||||
.insert(systemOptions)
|
||||
.values({ key, value, public: isPublic })
|
||||
.onConflictDoUpdate({ target: systemOptions.key, set: { value, public: isPublic } })
|
||||
},
|
||||
|
||||
async delete(key) {
|
||||
await db.delete(systemOptions).where(eq(systemOptions.key, key))
|
||||
},
|
||||
}
|
||||
}
|
||||
+26
-26
@@ -1,8 +1,8 @@
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { acceptInviteLink, createInviteLink, getInviteLinkInfo, listPendingInvitations } from './team-invite.js'
|
||||
import * as authSchema from '../../db/auth-schema.js'
|
||||
import { createTestApp } from '../../test/setup.js'
|
||||
import { createTeamInviteRepo } from './team-invite.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
@@ -66,7 +66,7 @@ describe('createInviteLink', () => {
|
||||
const orgId = await insertOrg(db)
|
||||
const inviterId = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
|
||||
expect(link.token).toBeTruthy()
|
||||
expect(link.organizationId).toBe(orgId)
|
||||
expect(link.role).toBe('viewer')
|
||||
@@ -81,7 +81,7 @@ describe('createInviteLink', () => {
|
||||
|
||||
const oneHour = 60 * 60 * 1000
|
||||
const before = Date.now()
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'editor', oneHour)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'editor', oneHour)
|
||||
const after = Date.now()
|
||||
|
||||
expect(link.expiresAt!.getTime()).toBeGreaterThan(before + oneHour - 1000)
|
||||
@@ -94,8 +94,8 @@ describe('createInviteLink', () => {
|
||||
const inviterId = await insertUser(db)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
createInviteLink(db, orgId, inviterId, 'viewer'),
|
||||
createInviteLink(db, orgId, inviterId, 'viewer'),
|
||||
createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer'),
|
||||
createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer'),
|
||||
])
|
||||
expect(a.token).not.toBe(b.token)
|
||||
})
|
||||
@@ -107,8 +107,8 @@ describe('getInviteLinkInfo', () => {
|
||||
const orgId = await insertOrg(db, { name: 'My Team' })
|
||||
const inviterId = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'editor')
|
||||
const info = await getInviteLinkInfo(db, link.token)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'editor')
|
||||
const info = await createTeamInviteRepo(db).getInviteLinkInfo(link.token)
|
||||
|
||||
expect(info).not.toBeNull()
|
||||
expect(info!.organizationId).toBe(orgId)
|
||||
@@ -118,7 +118,7 @@ describe('getInviteLinkInfo', () => {
|
||||
|
||||
it('returns null for an unknown token', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const info = await getInviteLinkInfo(db, 'nonexistent-token')
|
||||
const info = await createTeamInviteRepo(db).getInviteLinkInfo('nonexistent-token')
|
||||
expect(info).toBeNull()
|
||||
})
|
||||
|
||||
@@ -127,8 +127,8 @@ describe('getInviteLinkInfo', () => {
|
||||
const orgId = await insertOrg(db)
|
||||
const inviterId = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000) // already expired
|
||||
const info = await getInviteLinkInfo(db, link.token)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer', -1000) // already expired
|
||||
const info = await createTeamInviteRepo(db).getInviteLinkInfo(link.token)
|
||||
expect(info).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -140,15 +140,15 @@ describe('acceptInviteLink', () => {
|
||||
const inviterId = await insertUser(db)
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
|
||||
const result = await acceptInviteLink(db, link.token, userId)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
|
||||
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
|
||||
expect(result).toBe('ok')
|
||||
})
|
||||
|
||||
it('returns invalid for a nonexistent token', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const result = await acceptInviteLink(db, 'bad-token', userId)
|
||||
const result = await createTeamInviteRepo(db).acceptInviteLink('bad-token', userId)
|
||||
expect(result).toBe('invalid')
|
||||
})
|
||||
|
||||
@@ -158,8 +158,8 @@ describe('acceptInviteLink', () => {
|
||||
const inviterId = await insertUser(db)
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer', -1000)
|
||||
const result = await acceptInviteLink(db, link.token, userId)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer', -1000)
|
||||
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
|
||||
expect(result).toBe('expired')
|
||||
})
|
||||
|
||||
@@ -170,8 +170,8 @@ describe('acceptInviteLink', () => {
|
||||
const userId = await insertUser(db)
|
||||
await insertMember(db, orgId, userId, 'viewer')
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
|
||||
const result = await acceptInviteLink(db, link.token, userId)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
|
||||
const result = await createTeamInviteRepo(db).acceptInviteLink(link.token, userId)
|
||||
expect(result).toBe('already_member')
|
||||
})
|
||||
|
||||
@@ -182,9 +182,9 @@ describe('acceptInviteLink', () => {
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
const link = await createInviteLink(db, orgId, inviterId, 'viewer')
|
||||
const r1 = await acceptInviteLink(db, link.token, user1)
|
||||
const r2 = await acceptInviteLink(db, link.token, user2)
|
||||
const link = await createTeamInviteRepo(db).createInviteLink(orgId, inviterId, 'viewer')
|
||||
const r1 = await createTeamInviteRepo(db).acceptInviteLink(link.token, user1)
|
||||
const r2 = await createTeamInviteRepo(db).acceptInviteLink(link.token, user2)
|
||||
expect(r1).toBe('ok')
|
||||
expect(r2).toBe('ok')
|
||||
})
|
||||
@@ -194,7 +194,7 @@ describe('listPendingInvitations', () => {
|
||||
it('returns empty list when no pending invitations', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const orgId = await insertOrg(db)
|
||||
const result = await listPendingInvitations(db, orgId)
|
||||
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
@@ -204,7 +204,7 @@ describe('listPendingInvitations', () => {
|
||||
const inviterId = await insertUser(db)
|
||||
await insertInvitation(db, orgId, inviterId, 'test@example.com')
|
||||
|
||||
const result = await listPendingInvitations(db, orgId)
|
||||
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].email).toBe('test@example.com')
|
||||
expect(result[0].role).toBe('viewer')
|
||||
@@ -217,7 +217,7 @@ describe('listPendingInvitations', () => {
|
||||
await insertInvitation(db, orgId, inviterId, 'accepted@example.com', 'accepted')
|
||||
await insertInvitation(db, orgId, inviterId, 'pending@example.com', 'pending')
|
||||
|
||||
const result = await listPendingInvitations(db, orgId)
|
||||
const result = await createTeamInviteRepo(db).listPendingInvitations(orgId)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].email).toBe('pending@example.com')
|
||||
})
|
||||
@@ -230,7 +230,7 @@ describe('listPendingInvitations', () => {
|
||||
await insertInvitation(db, org1, inviterId, 'org1@example.com')
|
||||
await insertInvitation(db, org2, inviterId, 'org2@example.com')
|
||||
|
||||
const result = await listPendingInvitations(db, org1)
|
||||
const result = await createTeamInviteRepo(db).listPendingInvitations(org1)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].email).toBe('org1@example.com')
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { and, desc, eq, gt, isNull, or } from 'drizzle-orm'
|
||||
import { customAlphabet, nanoid } from 'nanoid'
|
||||
import { invitation, member, organization } from '../../db/auth-schema'
|
||||
import { teamInviteLinks } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { TeamInviteLinkRecord, TeamInviteRepo } from '../../usecases/ports'
|
||||
|
||||
const generateToken = customAlphabet('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz', 32)
|
||||
const DEFAULT_EXPIRES_IN_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||
|
||||
export function createTeamInviteRepo(db: Database): TeamInviteRepo {
|
||||
return {
|
||||
async createInviteLink(organizationId, inviterId, role, expiresIn) {
|
||||
const token = generateToken()
|
||||
const now = new Date()
|
||||
const row: TeamInviteLinkRecord = {
|
||||
id: nanoid(),
|
||||
token,
|
||||
organizationId,
|
||||
role,
|
||||
inviterId,
|
||||
expiresAt: new Date(now.getTime() + (expiresIn ?? DEFAULT_EXPIRES_IN_MS)),
|
||||
createdAt: now,
|
||||
}
|
||||
await db.insert(teamInviteLinks).values(row)
|
||||
return row
|
||||
},
|
||||
|
||||
async getInviteLinkInfo(token) {
|
||||
const rows = await db
|
||||
.select({
|
||||
organizationId: teamInviteLinks.organizationId,
|
||||
role: teamInviteLinks.role,
|
||||
expiresAt: teamInviteLinks.expiresAt,
|
||||
organizationName: organization.name,
|
||||
})
|
||||
.from(teamInviteLinks)
|
||||
.innerJoin(organization, eq(teamInviteLinks.organizationId, organization.id))
|
||||
.where(
|
||||
and(
|
||||
eq(teamInviteLinks.token, token),
|
||||
or(isNull(teamInviteLinks.expiresAt), gt(teamInviteLinks.expiresAt, new Date())),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
organizationId: row.organizationId,
|
||||
organizationName: row.organizationName,
|
||||
role: row.role,
|
||||
expiresAt: row.expiresAt,
|
||||
}
|
||||
},
|
||||
|
||||
async acceptInviteLink(token, userId) {
|
||||
const rows = await db.select().from(teamInviteLinks).where(eq(teamInviteLinks.token, token)).limit(1)
|
||||
const link = rows[0]
|
||||
if (!link) return 'invalid'
|
||||
if (link.expiresAt && link.expiresAt < new Date()) return 'expired'
|
||||
|
||||
const existing = await db
|
||||
.select({ id: member.id })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, link.organizationId), eq(member.userId, userId)))
|
||||
.limit(1)
|
||||
if (existing[0]) return 'already_member'
|
||||
|
||||
await db.insert(member).values({
|
||||
id: nanoid(),
|
||||
organizationId: link.organizationId,
|
||||
userId,
|
||||
role: link.role,
|
||||
createdAt: new Date(),
|
||||
})
|
||||
return 'ok'
|
||||
},
|
||||
|
||||
async listPendingInvitations(organizationId) {
|
||||
return db
|
||||
.select({
|
||||
id: invitation.id,
|
||||
email: invitation.email,
|
||||
role: invitation.role,
|
||||
expiresAt: invitation.expiresAt,
|
||||
createdAt: invitation.createdAt,
|
||||
})
|
||||
.from(invitation)
|
||||
.where(and(eq(invitation.organizationId, organizationId), eq(invitation.status, 'pending')))
|
||||
.orderBy(desc(invitation.createdAt))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { and, count, eq, inArray, like, not } from 'drizzle-orm'
|
||||
import { member, organization, user } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { TeamRepo, TeamSummary } from '../../usecases/ports'
|
||||
import { createQuotaRepo } from './quota'
|
||||
|
||||
// Personal orgs use the deterministic `personal-${userId}` slug; everything else
|
||||
// is a team. Production teams may have null metadata, so the slug prefix is the
|
||||
// reliable discriminator (not metadata.type).
|
||||
const isTeamSlug = not(like(organization.slug, 'personal-%'))
|
||||
|
||||
function chunk<T>(items: T[], size: number): T[][] {
|
||||
const out: T[][] = []
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
out.push(items.slice(i, i + size))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// First owner per org (by member creation order), for a display label.
|
||||
async function listOwnerNames(db: Database, orgIds: string[]): Promise<Map<string, string>> {
|
||||
// D1 caps a query at 100 bound params; chunk the IN list (plus the role param).
|
||||
const chunks = await Promise.all(
|
||||
chunk(orgIds, 90).map((ids) =>
|
||||
db
|
||||
.select({
|
||||
orgId: member.organizationId,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
createdAt: member.createdAt,
|
||||
})
|
||||
.from(member)
|
||||
.innerJoin(user, eq(user.id, member.userId))
|
||||
.where(and(eq(member.role, 'owner'), inArray(member.organizationId, ids)))
|
||||
.orderBy(member.createdAt),
|
||||
),
|
||||
)
|
||||
|
||||
const byOrg = new Map<string, string>()
|
||||
for (const r of chunks.flat()) {
|
||||
if (!byOrg.has(r.orgId)) byOrg.set(r.orgId, r.name || r.email)
|
||||
}
|
||||
return byOrg
|
||||
}
|
||||
|
||||
export function createTeamRepo(db: Database): TeamRepo {
|
||||
const quota = createQuotaRepo(db)
|
||||
|
||||
return {
|
||||
async listTeams() {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
logo: organization.logo,
|
||||
createdAt: organization.createdAt,
|
||||
})
|
||||
.from(organization)
|
||||
.where(isTeamSlug)
|
||||
.orderBy(organization.name)
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
const orgIds = rows.map((r) => r.id)
|
||||
const quotas = await quota.getEffectiveQuotasByOrg(orgIds)
|
||||
|
||||
// D1 caps a query at 100 bound params; chunk the IN lists below the cap.
|
||||
const memberChunks = await Promise.all(
|
||||
chunk(orgIds, 90).map((ids) =>
|
||||
db
|
||||
.select({ orgId: member.organizationId, total: count() })
|
||||
.from(member)
|
||||
.where(inArray(member.organizationId, ids))
|
||||
.groupBy(member.organizationId),
|
||||
),
|
||||
)
|
||||
const memberByOrg = new Map(memberChunks.flat().map((r) => [r.orgId, r.total]))
|
||||
const owners = await listOwnerNames(db, orgIds)
|
||||
|
||||
return rows.map((r) => {
|
||||
const q = quotas.get(r.id)
|
||||
return {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
slug: r.slug,
|
||||
logo: r.logo,
|
||||
memberCount: memberByOrg.get(r.id) ?? 0,
|
||||
ownerName: owners.get(r.id) ?? null,
|
||||
quotaUsed: q?.used ?? 0,
|
||||
quotaTotal: q?.quota ?? 0,
|
||||
createdAt: r.createdAt.getTime(),
|
||||
} satisfies TeamSummary
|
||||
})
|
||||
},
|
||||
|
||||
async getTeam(orgId) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: organization.id,
|
||||
name: organization.name,
|
||||
slug: organization.slug,
|
||||
logo: organization.logo,
|
||||
createdAt: organization.createdAt,
|
||||
})
|
||||
.from(organization)
|
||||
.where(and(eq(organization.id, orgId), isTeamSlug))
|
||||
.limit(1)
|
||||
const org = rows[0]
|
||||
if (!org) return null
|
||||
|
||||
const q = await quota.getEffectiveQuota(orgId)
|
||||
const [memberRow] = await db.select({ total: count() }).from(member).where(eq(member.organizationId, orgId))
|
||||
const owners = await listOwnerNames(db, [orgId])
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
logo: org.logo,
|
||||
memberCount: memberRow?.total ?? 0,
|
||||
ownerName: owners.get(orgId) ?? null,
|
||||
quotaUsed: q.used,
|
||||
quotaTotal: q.quota,
|
||||
createdAt: org.createdAt.getTime(),
|
||||
} satisfies TeamSummary
|
||||
},
|
||||
|
||||
async setLogo(orgId, logo) {
|
||||
await db.update(organization).set({ logo }).where(eq(organization.id, orgId))
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,18 @@
|
||||
import { and, count, desc, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import { member, organization, user } from '../db/auth-schema'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import {
|
||||
grantOrgEntitlement,
|
||||
listOrgEntitlements,
|
||||
revokeOrgEntitlement,
|
||||
updateOrgEntitlement,
|
||||
} from './org-entitlements'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { member, organization, user } from '../../db/auth-schema'
|
||||
import { orgQuotaEntitlements, orgQuotas } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
GrantEntitlementInput,
|
||||
QuotaEntitlementItem,
|
||||
UpdateEntitlementInput,
|
||||
UserAdminRepo,
|
||||
UserOperationFailure,
|
||||
UserWithOrg,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
export interface UserWithOrg {
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
email: string
|
||||
image: string | null
|
||||
role: string | null
|
||||
banned: boolean | null
|
||||
createdAt: Date
|
||||
orgId: string | null
|
||||
orgName: string | null
|
||||
quotaUsed: number
|
||||
quotaDefault: number
|
||||
quotaTotal: number
|
||||
}
|
||||
|
||||
export interface QuotaEntitlementItem {
|
||||
id: string
|
||||
orgId: string
|
||||
resourceType: string
|
||||
entitlementType: string
|
||||
source: string
|
||||
sourceId: string
|
||||
bytes: number
|
||||
startsAt: Date
|
||||
expiresAt: Date | null
|
||||
status: string
|
||||
metadata: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface UserOperationFailure {
|
||||
error: string
|
||||
status: 400 | 404
|
||||
}
|
||||
|
||||
export async function listUsers(
|
||||
async function listUsers(
|
||||
db: Database,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
@@ -101,7 +67,7 @@ export async function listUsers(
|
||||
return { items, total }
|
||||
}
|
||||
|
||||
export async function getUser(db: Database, userId: string): Promise<UserWithOrg | UserOperationFailure> {
|
||||
async function getUser(db: Database, userId: string): Promise<UserWithOrg | UserOperationFailure> {
|
||||
const now = new Date()
|
||||
const rows = await db
|
||||
.select({
|
||||
@@ -148,7 +114,7 @@ function activeStorageEntitlementBytesSql(now: Date) {
|
||||
)`
|
||||
}
|
||||
|
||||
export async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise<boolean> {
|
||||
async function setUserStatus(db: Database, userId: string, status: 'active' | 'disabled'): Promise<boolean> {
|
||||
const existing = await db.select({ id: user.id }).from(user).where(eq(user.id, userId))
|
||||
if (existing.length === 0) return false
|
||||
|
||||
@@ -159,7 +125,23 @@ export async function setUserStatus(db: Database, userId: string, status: 'activ
|
||||
return true
|
||||
}
|
||||
|
||||
export async function deleteUser(db: Database, userId: string): Promise<boolean> {
|
||||
async function isBanned(db: Database, userId: string): Promise<boolean> {
|
||||
const rows = await db.select({ banned: user.banned }).from(user).where(eq(user.id, userId))
|
||||
return Boolean(rows[0]?.banned)
|
||||
}
|
||||
|
||||
async function matchesUsername(db: Database, userId: string, username: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ email: user.email, username: user.username })
|
||||
.from(user)
|
||||
.where(eq(user.id, userId))
|
||||
.limit(1)
|
||||
const account = rows[0]
|
||||
if (!account) return false
|
||||
return account.email.toLowerCase() === username.toLowerCase() || account.username === username
|
||||
}
|
||||
|
||||
async function deleteUser(db: Database, userId: string): Promise<boolean> {
|
||||
const existing = await db.select({ id: user.id }).from(user).where(eq(user.id, userId))
|
||||
if (existing.length === 0) return false
|
||||
|
||||
@@ -167,7 +149,7 @@ export async function deleteUser(db: Database, userId: string): Promise<boolean>
|
||||
return true
|
||||
}
|
||||
|
||||
export async function setUsersStatus(
|
||||
async function setUsersStatus(
|
||||
db: Database,
|
||||
userIds: string[],
|
||||
status: 'active' | 'disabled',
|
||||
@@ -182,7 +164,7 @@ export async function setUsersStatus(
|
||||
return { updated: existingIds.length, ids: existingIds }
|
||||
}
|
||||
|
||||
export async function deleteUsers(
|
||||
async function deleteUsers(
|
||||
db: Database,
|
||||
userIds: string[],
|
||||
): Promise<{ deleted: number; ids: string[] } | UserOperationFailure> {
|
||||
@@ -193,7 +175,7 @@ export async function deleteUsers(
|
||||
return { deleted: existingIds.length, ids: existingIds }
|
||||
}
|
||||
|
||||
export async function listUserPersonalEntitlements(
|
||||
async function listUserPersonalEntitlements(
|
||||
db: Database,
|
||||
userId: string,
|
||||
): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> {
|
||||
@@ -202,7 +184,7 @@ export async function listUserPersonalEntitlements(
|
||||
return listOrgEntitlements(db, org.orgId)
|
||||
}
|
||||
|
||||
export async function grantUserPersonalEntitlement(
|
||||
async function grantUserPersonalEntitlement(
|
||||
db: Database,
|
||||
input: {
|
||||
adminUserId: string
|
||||
@@ -218,7 +200,7 @@ export async function grantUserPersonalEntitlement(
|
||||
return grantOrgEntitlement(db, { ...input, orgId: org.orgId })
|
||||
}
|
||||
|
||||
export async function updateUserPersonalEntitlement(
|
||||
async function updateUserPersonalEntitlement(
|
||||
db: Database,
|
||||
input: {
|
||||
adminUserId: string
|
||||
@@ -234,7 +216,7 @@ export async function updateUserPersonalEntitlement(
|
||||
return updateOrgEntitlement(db, { ...input, orgId: org.orgId })
|
||||
}
|
||||
|
||||
export async function revokeUserPersonalEntitlement(
|
||||
async function revokeUserPersonalEntitlement(
|
||||
db: Database,
|
||||
input: { adminUserId: string; targetUserId: string; entitlementId: string },
|
||||
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
|
||||
@@ -270,3 +252,139 @@ async function requireUsers(db: Database, userIds: string[]): Promise<string[] |
|
||||
}
|
||||
return uniqueIds
|
||||
}
|
||||
|
||||
// --- Org-scoped admin entitlement operations (formerly services/org-entitlements) ---
|
||||
|
||||
async function requireOrg(db: Database, orgId: string): Promise<{ orgId: string } | UserOperationFailure> {
|
||||
const rows = await db.select({ id: organization.id }).from(organization).where(eq(organization.id, orgId)).limit(1)
|
||||
if (!rows[0]) return { error: `Organization not found: ${orgId}`, status: 404 }
|
||||
return { orgId }
|
||||
}
|
||||
|
||||
async function listOrgEntitlements(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
): Promise<{ orgId: string; items: QuotaEntitlementItem[] } | UserOperationFailure> {
|
||||
const org = await requireOrg(db, orgId)
|
||||
if ('error' in org) return org
|
||||
const items = await db
|
||||
.select()
|
||||
.from(orgQuotaEntitlements)
|
||||
.where(eq(orgQuotaEntitlements.orgId, orgId))
|
||||
.orderBy(desc(orgQuotaEntitlements.createdAt))
|
||||
return { orgId, items }
|
||||
}
|
||||
|
||||
async function grantOrgEntitlement(
|
||||
db: Database,
|
||||
input: GrantEntitlementInput,
|
||||
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
|
||||
const org = await requireOrg(db, input.orgId)
|
||||
if ('error' in org) return org
|
||||
const now = new Date()
|
||||
const entitlement = {
|
||||
id: nanoid(),
|
||||
orgId: input.orgId,
|
||||
resourceType: input.resourceType,
|
||||
entitlementType: 'grant',
|
||||
source: 'admin_grant',
|
||||
sourceId: `admin_grant:${nanoid()}`,
|
||||
bytes: input.bytes,
|
||||
startsAt: now,
|
||||
expiresAt: input.expiresAt ?? null,
|
||||
status: 'active',
|
||||
metadata: JSON.stringify({ note: input.note ?? null, grantedBy: input.adminUserId }),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies typeof orgQuotaEntitlements.$inferInsert
|
||||
const rows = await db.insert(orgQuotaEntitlements).values(entitlement).returning()
|
||||
return { orgId: input.orgId, entitlement: rows[0] }
|
||||
}
|
||||
|
||||
async function updateOrgEntitlement(
|
||||
db: Database,
|
||||
input: UpdateEntitlementInput,
|
||||
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
|
||||
const existing = await findAdminGrant(db, input.orgId, input.entitlementId)
|
||||
if ('error' in existing) return existing
|
||||
|
||||
const metadata =
|
||||
input.note === undefined
|
||||
? existing.metadata
|
||||
: mergeGrantMetadata(existing.metadata, { note: input.note, updatedBy: input.adminUserId })
|
||||
const rows = await db
|
||||
.update(orgQuotaEntitlements)
|
||||
.set({
|
||||
bytes: input.bytes ?? existing.bytes,
|
||||
expiresAt: input.expiresAt === undefined ? existing.expiresAt : input.expiresAt,
|
||||
metadata,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(orgQuotaEntitlements.id, input.entitlementId))
|
||||
.returning()
|
||||
return { orgId: input.orgId, entitlement: rows[0] }
|
||||
}
|
||||
|
||||
async function revokeOrgEntitlement(
|
||||
db: Database,
|
||||
input: { adminUserId: string; orgId: string; entitlementId: string },
|
||||
): Promise<{ orgId: string; entitlement: QuotaEntitlementItem } | UserOperationFailure> {
|
||||
const existing = await findAdminGrant(db, input.orgId, input.entitlementId)
|
||||
if ('error' in existing) return existing
|
||||
|
||||
const rows = await db
|
||||
.update(orgQuotaEntitlements)
|
||||
.set({
|
||||
status: 'revoked',
|
||||
metadata: mergeGrantMetadata(existing.metadata, { revokedBy: input.adminUserId }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(orgQuotaEntitlements.id, input.entitlementId))
|
||||
.returning()
|
||||
return { orgId: input.orgId, entitlement: rows[0] }
|
||||
}
|
||||
|
||||
async function findAdminGrant(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
entitlementId: string,
|
||||
): Promise<QuotaEntitlementItem | UserOperationFailure> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(orgQuotaEntitlements)
|
||||
.where(and(eq(orgQuotaEntitlements.id, entitlementId), eq(orgQuotaEntitlements.orgId, orgId)))
|
||||
.limit(1)
|
||||
const row = rows[0]
|
||||
if (!row) return { error: `Entitlement not found: ${entitlementId}`, status: 404 }
|
||||
if (row.source !== 'admin_grant') {
|
||||
return { error: 'Only admin-granted entitlements can be modified', status: 400 }
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
function mergeGrantMetadata(existing: string | null, patch: Record<string, unknown>): string {
|
||||
const base = existing ? (JSON.parse(existing) as Record<string, unknown>) : {}
|
||||
return JSON.stringify({ ...base, ...patch })
|
||||
}
|
||||
|
||||
export function createUserAdminRepo(db: Database): UserAdminRepo {
|
||||
return {
|
||||
listUsers: (page, pageSize, search) => listUsers(db, page, pageSize, search),
|
||||
getUser: (userId) => getUser(db, userId),
|
||||
isBanned: (userId) => isBanned(db, userId),
|
||||
matchesUsername: (userId, username) => matchesUsername(db, userId, username),
|
||||
setUserStatus: (userId, status) => setUserStatus(db, userId, status),
|
||||
deleteUser: (userId) => deleteUser(db, userId),
|
||||
setUsersStatus: (userIds, status) => setUsersStatus(db, userIds, status),
|
||||
deleteUsers: (userIds) => deleteUsers(db, userIds),
|
||||
listUserPersonalEntitlements: (userId) => listUserPersonalEntitlements(db, userId),
|
||||
grantUserPersonalEntitlement: (input) => grantUserPersonalEntitlement(db, input),
|
||||
updateUserPersonalEntitlement: (input) => updateUserPersonalEntitlement(db, input),
|
||||
revokeUserPersonalEntitlement: (input) => revokeUserPersonalEntitlement(db, input),
|
||||
requireOrg: (orgId) => requireOrg(db, orgId),
|
||||
listOrgEntitlements: (orgId) => listOrgEntitlements(db, orgId),
|
||||
grantOrgEntitlement: (input) => grantOrgEntitlement(db, input),
|
||||
updateOrgEntitlement: (input) => updateOrgEntitlement(db, input),
|
||||
revokeOrgEntitlement: (input) => revokeOrgEntitlement(db, input),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { ObjectStatus } from '@shared/constants'
|
||||
import { and, asc, desc, eq } from 'drizzle-orm'
|
||||
import { member, organization } from '../../db/auth-schema'
|
||||
import { matters } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import {
|
||||
type Matter,
|
||||
WebDavPathError,
|
||||
type WebDavPathRepo,
|
||||
type WebDavTarget,
|
||||
type WebDavWorkspace,
|
||||
} from '../../usecases/ports'
|
||||
|
||||
export function createWebDavPathRepo(db: Database): WebDavPathRepo {
|
||||
async function getUserWorkspace(userId: string, slugOrId: string): Promise<WebDavWorkspace | null> {
|
||||
const rows = await db
|
||||
.select({ id: organization.id, name: organization.name, slug: organization.slug })
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||||
.where(and(eq(member.userId, userId), eq(organization.slug, slugOrId)))
|
||||
.limit(1)
|
||||
const row = rows[0] ?? (await getUserWorkspaceById(db, userId, slugOrId))
|
||||
return row ? { ...row, href: `/dav/${encodeURIComponent(row.slug)}/` } : null
|
||||
}
|
||||
|
||||
async function resolveWebDavPath(userId: string, rawPath: string): Promise<WebDavTarget> {
|
||||
const parts = decodeDavPath(rawPath)
|
||||
if (parts.length === 0) return { workspace: null, mountRoot: true, parent: '', name: '', matter: null }
|
||||
|
||||
const workspace = await getUserWorkspace(userId, parts[0])
|
||||
if (!workspace) throw new WebDavPathError('Workspace not found', 404)
|
||||
if (parts.length === 1) return { workspace, mountRoot: false, parent: '', name: '', matter: null }
|
||||
|
||||
const matterParts = parts.slice(1)
|
||||
const name = matterParts.at(-1) ?? ''
|
||||
const parent = matterParts.slice(0, -1).join('/')
|
||||
const matter = await findMatterByPath(db, workspace.id, parent, name)
|
||||
return { workspace, mountRoot: false, parent, name, matter }
|
||||
}
|
||||
|
||||
return {
|
||||
async listUserWorkspaces(userId) {
|
||||
const rows = await db
|
||||
.select({ id: organization.id, name: organization.name, slug: organization.slug })
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||||
.where(eq(member.userId, userId))
|
||||
.orderBy(asc(organization.name), asc(organization.slug))
|
||||
return rows.map((row) => ({ ...row, href: `/dav/${encodeURIComponent(row.slug)}/` }))
|
||||
},
|
||||
|
||||
async listChildren(orgId, parent) {
|
||||
return db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(and(eq(matters.orgId, orgId), eq(matters.parent, parent), eq(matters.status, ObjectStatus.ACTIVE)))
|
||||
.orderBy(desc(matters.dirtype), asc(matters.name))
|
||||
},
|
||||
|
||||
resolveWebDavPath,
|
||||
|
||||
async resolveExistingWebDavPath(userId, rawPath) {
|
||||
const target = await resolveWebDavPath(userId, rawPath)
|
||||
if (!target.matter) throw new WebDavPathError('Not found', 404)
|
||||
return target
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function decodeDavPath(rawPath: string): string[] {
|
||||
if (!rawPath.startsWith('/')) throw new WebDavPathError('Invalid DAV path', 400)
|
||||
if (rawPath.includes('//')) throw new WebDavPathError('Ambiguous DAV path', 400)
|
||||
|
||||
const withoutMount = rawPath.replace(/^\/dav(?:\/|$)/, '/')
|
||||
const trimmed = withoutMount.replace(/^\/+|\/+$/g, '')
|
||||
if (!trimmed) return []
|
||||
|
||||
return trimmed.split('/').map(decodeSegment)
|
||||
}
|
||||
|
||||
function decodeSegment(segment: string): string {
|
||||
if (!segment) throw new WebDavPathError('Ambiguous DAV path', 400)
|
||||
if (/%2f|%5c/i.test(segment)) throw new WebDavPathError('Encoded path separators are not allowed', 400)
|
||||
if (/%25(?:2e|2f|5c)/i.test(segment)) throw new WebDavPathError('Double-encoded path tricks are not allowed', 400)
|
||||
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = decodeURIComponent(segment)
|
||||
} catch {
|
||||
throw new WebDavPathError('Invalid path encoding', 400)
|
||||
}
|
||||
|
||||
if (!decoded || decoded === '.' || decoded === '..') throw new WebDavPathError('Invalid DAV path segment', 400)
|
||||
if (decoded.includes('/') || decoded.includes('\\')) throw new WebDavPathError('Invalid DAV path segment', 400)
|
||||
return decoded
|
||||
}
|
||||
|
||||
async function findMatterByPath(db: Database, orgId: string, parent: string, name: string): Promise<Matter | null> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(matters)
|
||||
.where(
|
||||
and(
|
||||
eq(matters.orgId, orgId),
|
||||
eq(matters.parent, parent),
|
||||
eq(matters.name, name),
|
||||
eq(matters.status, ObjectStatus.ACTIVE),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
async function getUserWorkspaceById(db: Database, userId: string, orgId: string) {
|
||||
const rows = await db
|
||||
.select({ id: organization.id, name: organization.name, slug: organization.slug })
|
||||
.from(member)
|
||||
.innerJoin(organization, eq(organization.id, member.organizationId))
|
||||
.where(and(eq(member.userId, userId), eq(organization.id, orgId)))
|
||||
.limit(1)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { env } from 'cloudflare:workers'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createCloudflarePlatform } from '../../platform/cloudflare'
|
||||
import { createWebDavStateRepo } from './webdav-state'
|
||||
|
||||
function buildRepo() {
|
||||
return createWebDavStateRepo(createCloudflarePlatform(env).db)
|
||||
}
|
||||
|
||||
describe('[CF] WebDAV locks on D1', () => {
|
||||
it('matches depth-infinity lock scopes without D1 dynamic LIKE expressions', async () => {
|
||||
const repo = buildRepo()
|
||||
const orgId = `org-${nanoid(8)}`
|
||||
const parent = await repo.createLock({
|
||||
orgId,
|
||||
resourcePath: 'Folder',
|
||||
owner: 'tester',
|
||||
depth: 'infinity',
|
||||
timeoutSeconds: 3600,
|
||||
})
|
||||
await repo.createLock({
|
||||
orgId,
|
||||
resourcePath: 'Other',
|
||||
owner: 'tester',
|
||||
depth: '0',
|
||||
timeoutSeconds: 3600,
|
||||
})
|
||||
|
||||
expect((await repo.activeLocks(orgId, 'Folder/child.txt')).map((lock) => lock.id)).toEqual([parent.id])
|
||||
expect((await repo.conflictingLocks(orgId, 'Folder')).map((lock) => lock.id)).toEqual([parent.id])
|
||||
expect(await repo.refreshLock(orgId, 'Folder/child.txt', parent.token, 3600)).toMatchObject({ id: parent.id })
|
||||
expect(await repo.removeLock(orgId, 'Folder/child.txt', parent.token)).toBe(true)
|
||||
expect(await repo.activeLocks(orgId, 'Folder/child.txt')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,286 @@
|
||||
import { and, eq, inArray, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { webdavDeadProperties, webdavLocks } from '../../db/schema'
|
||||
import { type AtomicQuery, executeWriteTransaction } from '../../db/transaction'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { DavDeadProperty, DavLock, WebDavStateRepo } from '../../usecases/ports'
|
||||
|
||||
export function createWebDavStateRepo(db: Database): WebDavStateRepo {
|
||||
return {
|
||||
async listDeadPropertiesForResources(orgId, resourcePaths) {
|
||||
const uniquePaths = [...new Set(resourcePaths)]
|
||||
const result = new Map(uniquePaths.map((path) => [path, [] as DavDeadProperty[]]))
|
||||
if (uniquePaths.length === 0) return result
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
resourcePath: webdavDeadProperties.resourcePath,
|
||||
namespace: webdavDeadProperties.namespace,
|
||||
name: webdavDeadProperties.name,
|
||||
value: webdavDeadProperties.value,
|
||||
})
|
||||
.from(webdavDeadProperties)
|
||||
.where(and(eq(webdavDeadProperties.orgId, orgId), inArray(webdavDeadProperties.resourcePath, uniquePaths)))
|
||||
|
||||
for (const row of rows) {
|
||||
result.get(row.resourcePath)?.push({ namespace: row.namespace, name: row.name, value: row.value })
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async applyDeadPropertyUpdate(orgId, resourcePath, operations) {
|
||||
const now = new Date()
|
||||
const queries: AtomicQuery[] = []
|
||||
for (const operation of operations) {
|
||||
if (operation.action === 'remove') {
|
||||
queries.push(
|
||||
db
|
||||
.delete(webdavDeadProperties)
|
||||
.where(
|
||||
and(
|
||||
eq(webdavDeadProperties.orgId, orgId),
|
||||
eq(webdavDeadProperties.resourcePath, resourcePath),
|
||||
eq(webdavDeadProperties.namespace, operation.property.namespace),
|
||||
eq(webdavDeadProperties.name, operation.property.name),
|
||||
),
|
||||
),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const property = operation.property
|
||||
queries.push(
|
||||
db
|
||||
.insert(webdavDeadProperties)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
resourcePath,
|
||||
namespace: property.namespace,
|
||||
name: property.name,
|
||||
value: property.value,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
webdavDeadProperties.orgId,
|
||||
webdavDeadProperties.resourcePath,
|
||||
webdavDeadProperties.namespace,
|
||||
webdavDeadProperties.name,
|
||||
],
|
||||
set: { value: property.value, updatedAt: now },
|
||||
}),
|
||||
)
|
||||
}
|
||||
await executeWriteTransaction(db, queries)
|
||||
},
|
||||
|
||||
async copyDeadProperties(orgId, sourcePath, targetPath) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavDeadProperties)
|
||||
.where(and(eq(webdavDeadProperties.orgId, orgId), eq(webdavDeadProperties.resourcePath, sourcePath)))
|
||||
if (rows.length === 0) return
|
||||
|
||||
const now = new Date()
|
||||
await executeWriteTransaction(
|
||||
db,
|
||||
rows.map((row) =>
|
||||
db
|
||||
.insert(webdavDeadProperties)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
orgId,
|
||||
resourcePath: targetPath,
|
||||
namespace: row.namespace,
|
||||
name: row.name,
|
||||
value: row.value,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
webdavDeadProperties.orgId,
|
||||
webdavDeadProperties.resourcePath,
|
||||
webdavDeadProperties.namespace,
|
||||
webdavDeadProperties.name,
|
||||
],
|
||||
set: { value: row.value, updatedAt: now },
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
async deleteWebDavState(orgId, resourcePath) {
|
||||
await executeWriteTransaction(db, [
|
||||
db
|
||||
.delete(webdavDeadProperties)
|
||||
.where(
|
||||
and(
|
||||
eq(webdavDeadProperties.orgId, orgId),
|
||||
or(
|
||||
eq(webdavDeadProperties.resourcePath, resourcePath),
|
||||
sql`${webdavDeadProperties.resourcePath} LIKE ${`${resourcePath}/%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
db
|
||||
.delete(webdavLocks)
|
||||
.where(
|
||||
and(
|
||||
eq(webdavLocks.orgId, orgId),
|
||||
or(
|
||||
eq(webdavLocks.resourcePath, resourcePath),
|
||||
sql`${webdavLocks.resourcePath} LIKE ${`${resourcePath}/%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
])
|
||||
},
|
||||
|
||||
async moveWebDavState(orgId, oldPath, newPath) {
|
||||
const now = new Date()
|
||||
await executeWriteTransaction(db, [
|
||||
db
|
||||
.update(webdavDeadProperties)
|
||||
.set({
|
||||
resourcePath: sql`CASE WHEN ${webdavDeadProperties.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavDeadProperties.resourcePath}, ${oldPath.length + 1}) END`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(webdavDeadProperties.orgId, orgId),
|
||||
or(
|
||||
eq(webdavDeadProperties.resourcePath, oldPath),
|
||||
sql`${webdavDeadProperties.resourcePath} LIKE ${`${oldPath}/%`}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
db
|
||||
.update(webdavLocks)
|
||||
.set({
|
||||
resourcePath: sql`CASE WHEN ${webdavLocks.resourcePath} = ${oldPath} THEN ${newPath} ELSE ${newPath} || SUBSTR(${webdavLocks.resourcePath}, ${oldPath.length + 1}) END`,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(webdavLocks.orgId, orgId),
|
||||
or(eq(webdavLocks.resourcePath, oldPath), sql`${webdavLocks.resourcePath} LIKE ${`${oldPath}/%`}`),
|
||||
),
|
||||
),
|
||||
])
|
||||
},
|
||||
|
||||
async activeLocks(orgId, resourcePath) {
|
||||
await purgeExpiredLocks(db)
|
||||
const now = Date.now()
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavLocks)
|
||||
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
|
||||
return rows.filter((lock) => lockAppliesToResource(lock, resourcePath))
|
||||
},
|
||||
|
||||
async activeLocksForResources(orgId, resourcePaths) {
|
||||
const uniquePaths = [...new Set(resourcePaths)]
|
||||
const result = new Map(uniquePaths.map((path) => [path, [] as DavLock[]]))
|
||||
if (uniquePaths.length === 0) return result
|
||||
|
||||
await purgeExpiredLocks(db)
|
||||
const now = Date.now()
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavLocks)
|
||||
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
|
||||
|
||||
for (const path of uniquePaths) {
|
||||
result.set(
|
||||
path,
|
||||
rows.filter((lock) => lockAppliesToResource(lock, path)),
|
||||
)
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
async conflictingLocks(orgId, resourcePath) {
|
||||
await purgeExpiredLocks(db)
|
||||
const now = Date.now()
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavLocks)
|
||||
.where(and(eq(webdavLocks.orgId, orgId), sql`${webdavLocks.expiresAt} > ${now}`))
|
||||
return rows.filter((lock) => lockConflictsWithResource(lock, resourcePath))
|
||||
},
|
||||
|
||||
async createLock(input) {
|
||||
const now = new Date()
|
||||
const lock: DavLock = {
|
||||
id: nanoid(),
|
||||
token: `opaquelocktoken:${crypto.randomUUID()}`,
|
||||
orgId: input.orgId,
|
||||
resourcePath: input.resourcePath,
|
||||
owner: input.owner,
|
||||
depth: input.depth,
|
||||
expiresAt: new Date(now.getTime() + input.timeoutSeconds * 1000),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
await db.insert(webdavLocks).values(lock)
|
||||
return lock
|
||||
},
|
||||
|
||||
async refreshLock(orgId, resourcePath, token, timeoutSeconds) {
|
||||
await purgeExpiredLocks(db)
|
||||
const now = new Date()
|
||||
const expiresAt = new Date(now.getTime() + timeoutSeconds * 1000)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavLocks)
|
||||
.where(
|
||||
and(
|
||||
eq(webdavLocks.orgId, orgId),
|
||||
eq(webdavLocks.token, token),
|
||||
sql`${webdavLocks.expiresAt} > ${now.getTime()}`,
|
||||
),
|
||||
)
|
||||
const lock = rows.find((row) => lockAppliesToResource(row, resourcePath))
|
||||
if (!lock) return null
|
||||
await db.update(webdavLocks).set({ expiresAt, updatedAt: now }).where(eq(webdavLocks.id, lock.id))
|
||||
return { ...lock, expiresAt, updatedAt: now }
|
||||
},
|
||||
|
||||
async removeLock(orgId, resourcePath, token) {
|
||||
await purgeExpiredLocks(db)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(webdavLocks)
|
||||
.where(
|
||||
and(
|
||||
eq(webdavLocks.orgId, orgId),
|
||||
eq(webdavLocks.token, token),
|
||||
sql`${webdavLocks.expiresAt} > ${Date.now()}`,
|
||||
),
|
||||
)
|
||||
const lock = rows.find((row) => lockAppliesToResource(row, resourcePath))
|
||||
if (!lock) return false
|
||||
await db.delete(webdavLocks).where(eq(webdavLocks.id, lock.id))
|
||||
return true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeExpiredLocks(db: Database): Promise<void> {
|
||||
await db.delete(webdavLocks).where(sql`${webdavLocks.expiresAt} <= ${Date.now()}`)
|
||||
}
|
||||
|
||||
function lockAppliesToResource(lock: DavLock, resourcePath: string): boolean {
|
||||
if (lock.resourcePath === resourcePath) return true
|
||||
if (lock.depth !== 'infinity') return false
|
||||
if (lock.resourcePath === '') return true
|
||||
return resourcePath.startsWith(`${lock.resourcePath}/`)
|
||||
}
|
||||
|
||||
function lockConflictsWithResource(lock: DavLock, resourcePath: string): boolean {
|
||||
if (lockAppliesToResource(lock, resourcePath)) return true
|
||||
if (resourcePath === '') return lock.resourcePath !== ''
|
||||
return lock.resourcePath.startsWith(`${resourcePath}/`)
|
||||
}
|
||||
@@ -1,49 +1,22 @@
|
||||
import { and, eq, like, or } from 'drizzle-orm'
|
||||
import { Zip, ZipDeflate, ZipPassThrough, type Zippable, zipSync } from 'fflate'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
import type { Database } from '../platform/interface'
|
||||
import type { Matter } from './matter'
|
||||
import { DirType } from '../../../shared/constants'
|
||||
import { matters } from '../../db/schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type {
|
||||
CollectCompressionPlanOptions,
|
||||
CompressionPlan,
|
||||
CompressionSourceDirectory,
|
||||
CompressionSourceFile,
|
||||
Matter,
|
||||
ZipPlanRepo,
|
||||
} from '../../usecases/ports'
|
||||
import { ZIP_COMPRESS_LIMITS } from '../../usecases/ports'
|
||||
|
||||
export const ZIP_COMPRESS_LIMITS = {
|
||||
totalInputBytes: 512 * 1024 * 1024,
|
||||
singleFileBytes: 512 * 1024 * 1024,
|
||||
fileCount: 1000,
|
||||
directoryDepth: 10,
|
||||
} as const
|
||||
|
||||
export interface CompressionSourceFile {
|
||||
matter: Matter
|
||||
archivePath: string
|
||||
}
|
||||
|
||||
export interface CompressionSourceDirectory {
|
||||
archivePath: string
|
||||
}
|
||||
|
||||
export interface ZipSourceObject {
|
||||
archivePath: string
|
||||
bytes: Uint8Array
|
||||
}
|
||||
|
||||
export interface ZipSourceStream {
|
||||
archivePath: string
|
||||
openStream: () => Promise<ReadableStream<Uint8Array>>
|
||||
}
|
||||
|
||||
export interface CompressionPlan {
|
||||
files: CompressionSourceFile[]
|
||||
directories: CompressionSourceDirectory[]
|
||||
inputBytes: number
|
||||
outputName: string
|
||||
targetFolder: string
|
||||
}
|
||||
|
||||
export async function collectCompressionPlan(
|
||||
async function collectCompressionPlan(
|
||||
db: Database,
|
||||
orgId: string,
|
||||
matterIds: string[],
|
||||
opts: { targetFolder?: string; outputName?: string } = {},
|
||||
opts: CollectCompressionPlanOptions = {},
|
||||
): Promise<CompressionPlan> {
|
||||
const uniqueIds = [...new Set(matterIds)]
|
||||
if (uniqueIds.length > ZIP_COMPRESS_LIMITS.fileCount) {
|
||||
@@ -74,80 +47,6 @@ export async function collectCompressionPlan(
|
||||
}
|
||||
}
|
||||
|
||||
export function createZipArchive(
|
||||
objects: ZipSourceObject[],
|
||||
directories: CompressionSourceDirectory[] = [],
|
||||
): Uint8Array {
|
||||
const zippable: Zippable = {}
|
||||
for (const directory of directories) zippable[`${directory.archivePath}/`] = new Uint8Array()
|
||||
for (const object of objects) zippable[object.archivePath] = object.bytes
|
||||
return zipSync(zippable, { level: 6 })
|
||||
}
|
||||
|
||||
export function createZipArchiveStream(
|
||||
sources: ZipSourceStream[],
|
||||
directories: CompressionSourceDirectory[] = [],
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const zip = new Zip()
|
||||
zip.ondata = (error, chunk, final) => {
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
return
|
||||
}
|
||||
if (chunk) controller.enqueue(new Uint8Array(chunk))
|
||||
if (final) controller.close()
|
||||
}
|
||||
|
||||
void streamZipEntries(zip, sources, directories, async () => {}).catch((error) => {
|
||||
zip.terminate()
|
||||
controller.error(error)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function streamZipEntries(
|
||||
zip: Zip,
|
||||
sources: ZipSourceStream[],
|
||||
directories: CompressionSourceDirectory[],
|
||||
waitForWrites: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
for (const directory of directories) {
|
||||
const entry = new ZipPassThrough(`${directory.archivePath}/`)
|
||||
zip.add(entry)
|
||||
entry.push(new Uint8Array(), true)
|
||||
await waitForWrites()
|
||||
}
|
||||
|
||||
for (const source of sources) {
|
||||
const entry = new ZipDeflate(source.archivePath, { level: 6 })
|
||||
zip.add(entry)
|
||||
await pushStreamToZipEntry(await source.openStream(), entry, waitForWrites)
|
||||
}
|
||||
|
||||
zip.end()
|
||||
}
|
||||
|
||||
async function pushStreamToZipEntry(
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
entry: ZipDeflate,
|
||||
waitForWrites: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const reader = stream.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
entry.push(new Uint8Array(), true)
|
||||
await waitForWrites()
|
||||
return
|
||||
}
|
||||
entry.push(value, false)
|
||||
await waitForWrites()
|
||||
}
|
||||
}
|
||||
|
||||
function validateCompressionEntries(files: CompressionSourceFile[], directories: CompressionSourceDirectory[]): void {
|
||||
let totalBytes = 0
|
||||
const paths = new Set<string>()
|
||||
@@ -238,3 +137,9 @@ function directoryDepth(path: string): number {
|
||||
function buildPath(parent: string, name: string): string {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
export function createZipPlanRepo(db: Database): ZipPlanRepo {
|
||||
return {
|
||||
collectCompressionPlan: (orgId, matterIds, opts) => collectCompressionPlan(db, orgId, matterIds, opts),
|
||||
}
|
||||
}
|
||||
+42
-37
@@ -3,6 +3,38 @@ import type { Context } from 'hono'
|
||||
import { Hono } from 'hono'
|
||||
import { cors } from 'hono/cors'
|
||||
import type { Auth } from './auth'
|
||||
import { createDeps } from './composition'
|
||||
import { adminAnnouncements, announcements } from './http/announcements'
|
||||
import { adminAudit } from './http/audit'
|
||||
import { adminAuthProviders, publicAuthProviders } from './http/auth-providers'
|
||||
import backgroundJobs from './http/background-jobs'
|
||||
import { brandingAdmin, publicBranding } from './http/branding'
|
||||
import { cloudStore, cloudStoreWebhooks } from './http/cloud-store'
|
||||
import downloadTasks from './http/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from './http/downloaders'
|
||||
import emailConfig from './http/email-config'
|
||||
import { events } from './http/events'
|
||||
import ihost from './http/ihost'
|
||||
import ihostConfig from './http/ihost-config'
|
||||
import internal from './http/internal'
|
||||
import { adminInviteCodes, publicInviteCodes } from './http/invite-codes'
|
||||
import licensing from './http/licensing'
|
||||
import licensingAdmin from './http/licensing-admin'
|
||||
import { me } from './http/me'
|
||||
import { notifications } from './http/notifications'
|
||||
import objects from './http/objects'
|
||||
import profile from './http/profile'
|
||||
import { adminQuotas, userQuotas } from './http/quotas'
|
||||
import redirect from './http/redirect'
|
||||
import { authedShares, publicShares } from './http/shares'
|
||||
import { adminSiteInvitations, publicSiteInvitations } from './http/site-invitations'
|
||||
import storages from './http/storages'
|
||||
import system from './http/system'
|
||||
import { publicTeams, teams } from './http/teams'
|
||||
import { adminTeams } from './http/teams-admin'
|
||||
import trash from './http/trash'
|
||||
import users from './http/users'
|
||||
import webdav from './http/webdav'
|
||||
import { formatError } from './lib/errors'
|
||||
import { authMiddleware } from './middleware/auth'
|
||||
import { imageHostingDomain } from './middleware/image-hosting-domain'
|
||||
@@ -11,55 +43,28 @@ import type { Env } from './middleware/platform'
|
||||
import { platformMiddleware } from './middleware/platform'
|
||||
import { downloaderOpenAPIDocument } from './openapi/downloader'
|
||||
import type { Platform } from './platform/interface'
|
||||
import { adminAnnouncements, announcements } from './routes/announcements'
|
||||
import { adminAudit } from './routes/audit'
|
||||
import { adminAuthProviders, publicAuthProviders } from './routes/auth-providers'
|
||||
import backgroundJobs from './routes/background-jobs'
|
||||
import { brandingAdmin, publicBranding } from './routes/branding'
|
||||
import { cloudStore, cloudStoreWebhooks } from './routes/cloud-store'
|
||||
import downloadTasks from './routes/download-tasks'
|
||||
import downloaders, { downloaderSelfRoute } from './routes/downloaders'
|
||||
import emailConfig from './routes/email-config'
|
||||
import { events } from './routes/events'
|
||||
import ihost from './routes/ihost'
|
||||
import ihostConfig from './routes/ihost-config'
|
||||
import internal from './routes/internal'
|
||||
import { adminInviteCodes, publicInviteCodes } from './routes/invite-codes'
|
||||
import licensing from './routes/licensing'
|
||||
import licensingAdmin from './routes/licensing-admin'
|
||||
import { me } from './routes/me'
|
||||
import { notifications } from './routes/notifications'
|
||||
import objects from './routes/objects'
|
||||
import profile from './routes/profile'
|
||||
import { adminQuotas, userQuotas } from './routes/quotas'
|
||||
import redirect from './routes/redirect'
|
||||
import { authedShares, publicShares } from './routes/shares'
|
||||
import { adminSiteInvitations, publicSiteInvitations } from './routes/site-invitations'
|
||||
import storages from './routes/storages'
|
||||
import system from './routes/system'
|
||||
import { publicTeams, teams } from './routes/teams'
|
||||
import { adminTeams } from './routes/teams-admin'
|
||||
import trash from './routes/trash'
|
||||
import users from './routes/users'
|
||||
import webdav from './routes/webdav'
|
||||
import { getDeployPlatform } from './runtime-platform'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry'
|
||||
import { ensureSitePublicOrigin } from './services/site-public-origin'
|
||||
import type { Deps } from './usecases/deps'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/instance-telemetry'
|
||||
import { ensureSitePublicOrigin } from './usecases/site-public-origin'
|
||||
|
||||
export function createApp(platform: Platform, auth: Auth) {
|
||||
export function createApp(platform: Platform, auth: Auth, deps: Deps = createDeps(platform)) {
|
||||
const app = new Hono<Env>()
|
||||
const corsOrigins = getCorsOrigins(platform)
|
||||
|
||||
app.use('/*', platformMiddleware(platform, auth))
|
||||
app.use('/*', async (c, next) => {
|
||||
const result = await ensureSitePublicOrigin(platform.db, c.req.url).catch((err) => {
|
||||
c.set('deps', deps)
|
||||
await next()
|
||||
})
|
||||
app.use('/*', async (c, next) => {
|
||||
const result = await ensureSitePublicOrigin(deps, c.req.url).catch((err) => {
|
||||
console.error(`site.public_origin.detect.error code=${formatError(err)}`)
|
||||
return { origin: null, created: false }
|
||||
})
|
||||
|
||||
if (result.created && result.origin && shouldReportInitialTelemetry(c.req.url)) {
|
||||
const task = reportInstanceTelemetry({
|
||||
db: platform.db,
|
||||
const task = reportInstanceTelemetry(deps, {
|
||||
config: {
|
||||
siteUrl: result.origin,
|
||||
allowIp: envAllowsIp(platform.getEnv('ZPAN_TELEMETRY_ALLOW_IP')),
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createInviteRepo } from './adapters/repos/invite.js'
|
||||
import { createSiteInvitationRepo } from './adapters/repos/site-invitations.js'
|
||||
import { createApp } from './app.js'
|
||||
import { createAuth } from './auth.js'
|
||||
import * as authSchema from './db/auth-schema.js'
|
||||
import * as schema from './db/schema.js'
|
||||
import { inviteCodes, siteInvitations } from './db/schema.js'
|
||||
import { generateInviteCodes } from './services/invite.js'
|
||||
import { createSiteInvitation } from './services/site-invitations.js'
|
||||
import { createTestApp, seedProLicense } from './test/setup.js'
|
||||
|
||||
type TestCtx = Awaited<ReturnType<typeof createTestApp>>
|
||||
@@ -126,7 +126,7 @@ describe('registration gate — closed mode', () => {
|
||||
.from(authSchema.user)
|
||||
.where(eq(authSchema.user.email, 'first@example.com'))
|
||||
.limit(1)
|
||||
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
|
||||
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
||||
|
||||
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
|
||||
|
||||
@@ -142,7 +142,7 @@ describe('registration gate — closed mode', () => {
|
||||
.from(authSchema.user)
|
||||
.where(eq(authSchema.user.email, 'first@example.com'))
|
||||
.limit(1)
|
||||
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
|
||||
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
||||
|
||||
const res = await signUp(ctx, 'invited@example.com', { siteInvitationToken: invitation.token })
|
||||
const body = (await res.json()) as { user: { id: string } }
|
||||
@@ -165,7 +165,7 @@ describe('registration gate — closed mode', () => {
|
||||
.from(authSchema.user)
|
||||
.where(eq(authSchema.user.email, 'first@example.com'))
|
||||
.limit(1)
|
||||
const invitation = await createSiteInvitation(ctx.db, admin.id, 'invited@example.com')
|
||||
const invitation = await createSiteInvitationRepo(ctx.db).createSiteInvitation(admin.id, 'invited@example.com')
|
||||
|
||||
const res = await signUp(ctx, 'other@example.com', { siteInvitationToken: invitation.token })
|
||||
|
||||
@@ -203,7 +203,7 @@ describe('registration gate — invite_only mode', () => {
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
||||
await signUp(ctx, 'first@example.com')
|
||||
const pastDate = new Date(Date.now() - 1000)
|
||||
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1, pastDate)
|
||||
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1, pastDate)
|
||||
const res = await signUp(ctx, 'expired@example.com', { inviteCode: codeRow.code })
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
@@ -212,7 +212,7 @@ describe('registration gate — invite_only mode', () => {
|
||||
const ctx = await createTestApp()
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
||||
await signUp(ctx, 'first@example.com')
|
||||
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
|
||||
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
||||
const res = await signUp(ctx, 'invited@example.com', { inviteCode: codeRow.code })
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
@@ -221,7 +221,7 @@ describe('registration gate — invite_only mode', () => {
|
||||
const ctx = await createTestApp()
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
||||
await signUp(ctx, 'first@example.com')
|
||||
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
|
||||
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
||||
const res = await signUp(ctx, 'invited2@example.com', { inviteCode: codeRow.code })
|
||||
const body = (await res.json()) as { user: { id: string } }
|
||||
const [row] = await ctx.db.select().from(inviteCodes).where(eq(inviteCodes.code, codeRow.code))
|
||||
@@ -233,7 +233,7 @@ describe('registration gate — invite_only mode', () => {
|
||||
const ctx = await createTestApp()
|
||||
await ctx.db.insert(schema.systemOptions).values({ key: 'auth_signup_mode', value: 'invite_only' })
|
||||
await signUp(ctx, 'first@example.com')
|
||||
const [codeRow] = await generateInviteCodes(ctx.db, 'admin-1', 1)
|
||||
const [codeRow] = await createInviteRepo(ctx.db).generate('admin-1', 1)
|
||||
await signUp(ctx, 'user1@example.com', { inviteCode: codeRow.code })
|
||||
const res = await signUp(ctx, 'user2@example.com', { inviteCode: codeRow.code })
|
||||
expect(res.status).not.toBe(200)
|
||||
|
||||
+82
-34
@@ -1,6 +1,7 @@
|
||||
import { apiKey } from '@better-auth/api-key'
|
||||
import { APIError, type BetterAuthPlugin, betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
import type { CaptchaOptions } from 'better-auth/plugins'
|
||||
import { admin, bearer, captcha, deviceAuthorization, organization, username } from 'better-auth/plugins'
|
||||
import { genericOAuth } from 'better-auth/plugins/generic-oauth'
|
||||
import { adminAc, memberAc, ownerAc } from 'better-auth/plugins/organization/access'
|
||||
@@ -20,23 +21,27 @@ import {
|
||||
type OAuthProviderConfig,
|
||||
parseProviderConfig,
|
||||
} from '../shared/oauth-providers'
|
||||
import { createEmailGateway } from './adapters/gateways/email'
|
||||
import { createActivityRepo } from './adapters/repos/activity'
|
||||
import { createInviteRepo } from './adapters/repos/invite'
|
||||
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
|
||||
import { createMemberCountRepo } from './adapters/repos/member-count'
|
||||
import { createNotificationRepo } from './adapters/repos/notification'
|
||||
import { createOrgRepo } from './adapters/repos/org'
|
||||
import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
|
||||
import { createSystemOptionsRepo } from './adapters/repos/system-options'
|
||||
import * as authSchema from './db/auth-schema'
|
||||
import { orgQuotaEntitlements, orgQuotas, systemOptions } from './db/schema'
|
||||
import { executeWriteTransaction } from './db/transaction'
|
||||
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig } from './domain/captcha'
|
||||
import { currentTrafficPeriod } from './domain/quota'
|
||||
import { isLocalNetworkOrigin } from './lib/local-origin'
|
||||
import { hashPassword, verifyPassword as verifyPasswordHash } from './lib/password'
|
||||
import { createDbProxy, createPlatformProxy } from './platform/context'
|
||||
import type { Database, Platform } from './platform/interface'
|
||||
import { recordActivity } from './services/activity'
|
||||
import { CAPTCHA_AUTH_ENDPOINTS, loadCaptchaConfig, toBetterAuthCaptchaOptions } from './services/captcha'
|
||||
import { executeWriteTransaction } from './services/db-transaction'
|
||||
import { currentTrafficPeriod } from './services/effective-quota'
|
||||
import { isEmailConfigured, sendEmail } from './services/email'
|
||||
import { redeemInviteCode, validateInviteCode } from './services/invite'
|
||||
import { createNotification } from './services/notification'
|
||||
import { findPersonalOrg } from './services/org'
|
||||
import { getEffectiveSignupMode } from './services/signup-mode-guard'
|
||||
import { acceptSiteInvitation, validateSiteInvitation } from './services/site-invitations'
|
||||
import { checkTeamLimit } from './services/team-count-guard'
|
||||
import { loadCaptchaConfig } from './usecases/captcha'
|
||||
import { getEffectiveSignupMode } from './usecases/signup-mode'
|
||||
import { checkTeamLimit } from './usecases/team-count'
|
||||
|
||||
// better-auth's default password hasher is pure-JS scrypt from @noble/hashes,
|
||||
// which blows past Cloudflare Workers' CPU budget and triggers error 1102.
|
||||
@@ -83,6 +88,27 @@ async function loadProviderConfigs(db: Database): Promise<ProviderConfigs> {
|
||||
return configs
|
||||
}
|
||||
|
||||
// Maps stored captcha config to the better-auth captcha plugin options. Lives
|
||||
// here because better-auth's CaptchaOptions type is delivery-framework-specific
|
||||
// and may not leak into the framework-free usecases/ layer.
|
||||
export function toBetterAuthCaptchaOptions(config: CaptchaConfig): CaptchaOptions {
|
||||
const base = {
|
||||
provider: config.provider,
|
||||
secretKey: config.secretKey,
|
||||
endpoints: [...CAPTCHA_AUTH_ENDPOINTS],
|
||||
}
|
||||
|
||||
if (config.provider === 'google-recaptcha') {
|
||||
return config.minScore === undefined ? base : { ...base, minScore: config.minScore }
|
||||
}
|
||||
|
||||
if (config.provider === 'hcaptcha' || config.provider === 'captchafox') {
|
||||
return { ...base, siteKey: config.siteKey }
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
function dynamicCaptcha(db: Database): BetterAuthPlugin {
|
||||
return {
|
||||
id: 'dynamic-captcha',
|
||||
@@ -91,7 +117,7 @@ function dynamicCaptcha(db: Database): BetterAuthPlugin {
|
||||
// for everything else (notably get-session, the hottest auth route).
|
||||
const path = new URL(request.url).pathname
|
||||
if (!CAPTCHA_AUTH_ENDPOINTS.some((endpoint) => path.endsWith(endpoint))) return
|
||||
const config = await loadCaptchaConfig(db)
|
||||
const config = await loadCaptchaConfig({ systemOptions: createSystemOptionsRepo(db) })
|
||||
if (!config) return
|
||||
const plugin = captcha(toBetterAuthCaptchaOptions(config))
|
||||
return plugin.onRequest?.(request, ctx)
|
||||
@@ -161,7 +187,12 @@ export async function createAuth(
|
||||
const dbProxy = platformProxy ? platformProxy.db : createDbProxy(rawDb)
|
||||
|
||||
const db = dbProxy
|
||||
const source = platformProxy || dbProxy
|
||||
// The email gateway needs a Platform for the Cloudflare EMAIL binding. On the
|
||||
// bare-Database path (tests, Node fallbacks) there is no platform, so wrap the
|
||||
// db proxy in a binding-free Platform — matching the previous behaviour where
|
||||
// a Database source had no CF binding available.
|
||||
const authPlatform: Platform = platformProxy ?? { db: dbProxy, getEnv: () => undefined, getBinding: () => undefined }
|
||||
const email = createEmailGateway(createSystemOptionsRepo(db))
|
||||
const providerConfigs = await loadProviderConfigs(rawDb)
|
||||
const auth = betterAuth({
|
||||
database: drizzleAdapter(db, { provider: 'sqlite', schema: authSchema }),
|
||||
@@ -189,8 +220,8 @@ export async function createAuth(
|
||||
verify: authVerifyPassword,
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
if (!(await isEmailConfigured(source))) return
|
||||
await sendEmail(source, {
|
||||
if (!(await email.isConfigured(authPlatform))) return
|
||||
await email.send(authPlatform, {
|
||||
to: user.email,
|
||||
subject: 'Reset your password - ZPan',
|
||||
html: buildResetPasswordEmailHtml(url),
|
||||
@@ -199,8 +230,8 @@ export async function createAuth(
|
||||
},
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
if (!(await isEmailConfigured(source))) return
|
||||
await sendEmail(source, {
|
||||
if (!(await email.isConfigured(authPlatform))) return
|
||||
await email.send(authPlatform, {
|
||||
to: user.email,
|
||||
subject: 'Verify your email - ZPan',
|
||||
html: buildVerificationEmailHtml(url),
|
||||
@@ -228,8 +259,8 @@ export async function createAuth(
|
||||
viewer: memberAc,
|
||||
},
|
||||
sendInvitationEmail: async (data) => {
|
||||
if (!(await isEmailConfigured(source))) return
|
||||
await sendEmail(source, {
|
||||
if (!(await email.isConfigured(authPlatform))) return
|
||||
await email.send(authPlatform, {
|
||||
to: data.email,
|
||||
subject: `You've been invited to join ${data.organization.name} - ZPan`,
|
||||
html: buildInvitationEmailHtml(data),
|
||||
@@ -237,7 +268,14 @@ export async function createAuth(
|
||||
},
|
||||
organizationHooks: {
|
||||
beforeCreateOrganization: async ({ user }) => {
|
||||
const { allowed, count: current_count, limit } = await checkTeamLimit(db, user.id)
|
||||
const {
|
||||
allowed,
|
||||
count: current_count,
|
||||
limit,
|
||||
} = await checkTeamLimit(
|
||||
{ memberCount: createMemberCountRepo(db), licenseBinding: createLicenseBindingRepo(db) },
|
||||
user.id,
|
||||
)
|
||||
if (!allowed) {
|
||||
throw new APIError('PAYMENT_REQUIRED', {
|
||||
message:
|
||||
@@ -254,7 +292,7 @@ export async function createAuth(
|
||||
await createOrgQuota(db, organization.id, new Date(), isTeam)
|
||||
},
|
||||
afterAcceptInvitation: async ({ member, user, organization }) => {
|
||||
await recordActivity(db, {
|
||||
await createActivityRepo(db).record({
|
||||
orgId: organization.id,
|
||||
userId: user.id,
|
||||
action: 'team_member_join',
|
||||
@@ -263,7 +301,7 @@ export async function createAuth(
|
||||
targetName: organization.name,
|
||||
metadata: { role: member.role },
|
||||
})
|
||||
await createNotification(db, {
|
||||
await createNotificationRepo(db).create({
|
||||
userId: user.id,
|
||||
type: 'team_join',
|
||||
title: `You joined ${organization.name}`,
|
||||
@@ -276,7 +314,7 @@ export async function createAuth(
|
||||
afterRemoveMember: async ({ member, organization }) => {
|
||||
// Better Auth does not expose the actor (initiator) in this hook;
|
||||
// member.userId is the removed user — used here as the attributed userId.
|
||||
await recordActivity(db, {
|
||||
await createActivityRepo(db).record({
|
||||
orgId: organization.id,
|
||||
userId: member.userId,
|
||||
action: 'team_member_remove',
|
||||
@@ -289,7 +327,7 @@ export async function createAuth(
|
||||
afterUpdateMemberRole: async ({ member, previousRole, organization }) => {
|
||||
// Better Auth does not expose the actor in this hook;
|
||||
// member.userId is the user whose role changed.
|
||||
await recordActivity(db, {
|
||||
await createActivityRepo(db).record({
|
||||
orgId: organization.id,
|
||||
userId: member.userId,
|
||||
action: 'team_member_role_update',
|
||||
@@ -301,7 +339,7 @@ export async function createAuth(
|
||||
},
|
||||
afterUpdateOrganization: async ({ organization, user }) => {
|
||||
if (!organization?.id || !user?.id) return
|
||||
await recordActivity(db, {
|
||||
await createActivityRepo(db).record({
|
||||
orgId: organization.id,
|
||||
userId: user.id,
|
||||
action: 'team_settings_update',
|
||||
@@ -311,7 +349,7 @@ export async function createAuth(
|
||||
})
|
||||
},
|
||||
afterDeleteOrganization: async ({ organization, user }) => {
|
||||
await recordActivity(db, {
|
||||
await createActivityRepo(db).record({
|
||||
orgId: organization.id,
|
||||
userId: user.id,
|
||||
action: 'team_delete',
|
||||
@@ -386,14 +424,17 @@ export async function createAuth(
|
||||
|
||||
// Registration gate: skip for the very first user so bootstrap works
|
||||
if (!firstUser) {
|
||||
const mode = await getEffectiveSignupMode(db)
|
||||
const mode = await getEffectiveSignupMode({
|
||||
systemOptions: createSystemOptionsRepo(db),
|
||||
licenseBinding: createLicenseBindingRepo(db),
|
||||
})
|
||||
const email = String(user.email ?? '')
|
||||
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
|
||||
if (mode === SignupMode.CLOSED) {
|
||||
if (!siteInvitationToken) {
|
||||
throw new Error('An invitation is required to register')
|
||||
}
|
||||
const validation = await validateSiteInvitation(db, siteInvitationToken, email)
|
||||
const validation = await createSiteInvitationRepo(db).validateSiteInvitation(siteInvitationToken, email)
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error ?? 'Invalid invitation')
|
||||
}
|
||||
@@ -403,7 +444,7 @@ export async function createAuth(
|
||||
if (!inviteCode) {
|
||||
throw new Error('An invite code is required to register')
|
||||
}
|
||||
const validation = await validateInviteCode(db, inviteCode)
|
||||
const validation = await createInviteRepo(db).validate(inviteCode)
|
||||
if (!validation.valid) {
|
||||
throw new Error(validation.error ?? 'Invalid invite code')
|
||||
}
|
||||
@@ -429,17 +470,24 @@ export async function createAuth(
|
||||
},
|
||||
after: async (user, context) => {
|
||||
// Redeem invite code after user is created (user.id is now available)
|
||||
const mode = await getEffectiveSignupMode(db)
|
||||
const mode = await getEffectiveSignupMode({
|
||||
systemOptions: createSystemOptionsRepo(db),
|
||||
licenseBinding: createLicenseBindingRepo(db),
|
||||
})
|
||||
if (mode === SignupMode.INVITE_ONLY) {
|
||||
const inviteCode = (context?.body as { inviteCode?: string })?.inviteCode
|
||||
if (inviteCode) {
|
||||
await redeemInviteCode(db, inviteCode, user.id)
|
||||
await createInviteRepo(db).redeem(inviteCode, user.id)
|
||||
}
|
||||
}
|
||||
|
||||
const siteInvitationToken = (context?.body as { siteInvitationToken?: string })?.siteInvitationToken
|
||||
if (siteInvitationToken) {
|
||||
const result = await acceptSiteInvitation(db, siteInvitationToken, user.email, user.id)
|
||||
const result = await createSiteInvitationRepo(db).acceptSiteInvitation(
|
||||
siteInvitationToken,
|
||||
user.email,
|
||||
user.id,
|
||||
)
|
||||
if (result !== 'ok' && result !== 'accepted') {
|
||||
throw new Error(`Failed to redeem site invitation: ${result}`)
|
||||
}
|
||||
@@ -450,7 +498,7 @@ export async function createAuth(
|
||||
// when autoSignIn is enabled the org is actually created by
|
||||
// session.create.before (which runs earlier, inside the txn).
|
||||
// The idempotent check ensures no duplicate is created.
|
||||
const existing = await findPersonalOrg(db, user.id)
|
||||
const existing = await createOrgRepo(db).findPersonalOrg(user.id)
|
||||
if (!existing) {
|
||||
await createPersonalOrg(db, user)
|
||||
}
|
||||
@@ -461,7 +509,7 @@ export async function createAuth(
|
||||
create: {
|
||||
before: async (session) => {
|
||||
// Look up existing personal org (returning users)
|
||||
let orgId = await findPersonalOrg(db, session.userId)
|
||||
let orgId = await createOrgRepo(db).findPersonalOrg(session.userId)
|
||||
|
||||
// For new sign-ups the org doesn't exist yet — create it now.
|
||||
// This runs inside the sign-up transaction, after the user row
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// The composition root. createDeps wires concrete adapters into the Deps object
|
||||
// the rest of the server consumes. This is the ONLY place adapters are
|
||||
// constructed. Keep it a cheap, request-free factory so the scheduled/queue
|
||||
// entrypoints can reuse it; request-bound capabilities are passed to usecases as
|
||||
// function parameters, never stored here.
|
||||
|
||||
import { createArchiveJobsGateway } from './adapters/gateways/archive-jobs'
|
||||
import { createEmailGateway } from './adapters/gateways/email'
|
||||
import { createImageUploadGateway } from './adapters/gateways/image-upload'
|
||||
import { createLicensingCloudGateway } from './adapters/gateways/licensing-cloud'
|
||||
import { S3Service } from './adapters/gateways/s3'
|
||||
import { createZipGateway } from './adapters/gateways/zip'
|
||||
import { createCfClient } from './adapters/providers/cf-custom-hostnames'
|
||||
import { createChangelogProvider } from './adapters/providers/changelog'
|
||||
import { createActivityRepo } from './adapters/repos/activity'
|
||||
import { createAnnouncementRepo } from './adapters/repos/announcement'
|
||||
import { createApiKeyGateway } from './adapters/repos/api-keys'
|
||||
import { createArchiveTargetFolderRepo } from './adapters/repos/archive-target-folder'
|
||||
import { createBackgroundJobRepo } from './adapters/repos/background-job'
|
||||
import { createCloudStoreRepo } from './adapters/repos/cloud-store'
|
||||
import { createCloudTrafficReportRepo } from './adapters/repos/cloud-traffic-report'
|
||||
import { createDownloadTaskRepo } from './adapters/repos/download-task'
|
||||
import { createDownloadTokenGateway } from './adapters/repos/download-tokens'
|
||||
import { createDownloaderRepo } from './adapters/repos/downloader'
|
||||
import { createImageHostingRepo } from './adapters/repos/image-hosting'
|
||||
import { createImageHostingConfigRepo } from './adapters/repos/image-hosting-config'
|
||||
import { createInstanceRepo } from './adapters/repos/instance'
|
||||
import { createInviteRepo } from './adapters/repos/invite'
|
||||
import { createLicenseBindingRepo } from './adapters/repos/license-binding'
|
||||
import { createMatterRepo } from './adapters/repos/matter'
|
||||
import { createMemberCountRepo } from './adapters/repos/member-count'
|
||||
import { createNotificationRepo } from './adapters/repos/notification'
|
||||
import { createObjectUploadSessionRepo } from './adapters/repos/object-upload-session'
|
||||
import { createOrgRepo } from './adapters/repos/org'
|
||||
import { createProfileRepo } from './adapters/repos/profile'
|
||||
import { createQuotaRepo } from './adapters/repos/quota'
|
||||
import { createRemoteDownloadUsageRepo } from './adapters/repos/remote-download-usage'
|
||||
import { createShareRepo } from './adapters/repos/share'
|
||||
import { createShareNotificationRepo } from './adapters/repos/share-notification'
|
||||
import { createSiteInvitationRepo } from './adapters/repos/site-invitations'
|
||||
import { createStorageRepo } from './adapters/repos/storage'
|
||||
import { createStorageUsageRepo } from './adapters/repos/storage-usage'
|
||||
import { createSystemOptionsRepo } from './adapters/repos/system-options'
|
||||
import { createTeamRepo } from './adapters/repos/team'
|
||||
import { createTeamInviteRepo } from './adapters/repos/team-invite'
|
||||
import { createUserAdminRepo } from './adapters/repos/user-admin'
|
||||
import { createWebDavPathRepo } from './adapters/repos/webdav-path'
|
||||
import { createWebDavStateRepo } from './adapters/repos/webdav-state'
|
||||
import { createZipPlanRepo } from './adapters/repos/zip'
|
||||
import type { Platform } from './platform/interface'
|
||||
import type { Deps } from './usecases/deps'
|
||||
|
||||
export function createDeps(platform: Platform): Deps {
|
||||
const { db } = platform
|
||||
// Shared stateless instances reused by multiple ports below.
|
||||
const s3 = new S3Service()
|
||||
const storages = createStorageRepo(db)
|
||||
const systemOptions = createSystemOptionsRepo(db)
|
||||
return {
|
||||
activity: createActivityRepo(db),
|
||||
announcements: createAnnouncementRepo(db),
|
||||
apiKeys: createApiKeyGateway(),
|
||||
archiveJobs: createArchiveJobsGateway(platform),
|
||||
archiveTargetFolders: createArchiveTargetFolderRepo(db),
|
||||
backgroundJobs: createBackgroundJobRepo(db),
|
||||
cfHostnames: createCfClient((key) => platform.getEnv(key)),
|
||||
changelog: createChangelogProvider(),
|
||||
cloudStore: createCloudStoreRepo(db),
|
||||
cloudTrafficReports: createCloudTrafficReportRepo(db),
|
||||
downloaders: createDownloaderRepo(db),
|
||||
downloadTasks: createDownloadTaskRepo(db),
|
||||
downloadTokens: createDownloadTokenGateway(),
|
||||
email: createEmailGateway(systemOptions),
|
||||
invites: createInviteRepo(db),
|
||||
imageHostingConfigs: createImageHostingConfigRepo(db),
|
||||
imageHosting: createImageHostingRepo(db),
|
||||
imageUpload: createImageUploadGateway(s3, storages),
|
||||
instance: createInstanceRepo(db),
|
||||
licenseBinding: createLicenseBindingRepo(db),
|
||||
licensingCloud: createLicensingCloudGateway(),
|
||||
matter: createMatterRepo(db),
|
||||
memberCount: createMemberCountRepo(db),
|
||||
notifications: createNotificationRepo(db),
|
||||
objectUploadSessions: createObjectUploadSessionRepo(db),
|
||||
org: createOrgRepo(db),
|
||||
profiles: createProfileRepo(db),
|
||||
quota: createQuotaRepo(db),
|
||||
remoteDownloadUsage: createRemoteDownloadUsageRepo(db),
|
||||
s3,
|
||||
shareNotifications: createShareNotificationRepo(db),
|
||||
share: createShareRepo(db),
|
||||
siteInvitations: createSiteInvitationRepo(db),
|
||||
storages,
|
||||
storageUsage: createStorageUsageRepo(db),
|
||||
systemOptions,
|
||||
teams: createTeamRepo(db),
|
||||
teamInvites: createTeamInviteRepo(db),
|
||||
userAdmin: createUserAdminRepo(db),
|
||||
webdavPath: createWebDavPathRepo(db),
|
||||
webdavState: createWebDavStateRepo(db),
|
||||
zip: createZipGateway(),
|
||||
zipPlan: createZipPlanRepo(db),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export function buildBreadcrumb(dir: string): string[] {
|
||||
if (!dir) return []
|
||||
return dir.split('/')
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
CAPTCHA_SECRET_OPTION_KEY,
|
||||
CAPTCHA_SITE_KEY_KEY,
|
||||
} from '../../shared/captcha.js'
|
||||
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig, readCaptchaConfig, toBetterAuthCaptchaOptions } from './captcha.js'
|
||||
import { toBetterAuthCaptchaOptions } from '../auth.js'
|
||||
import { CAPTCHA_AUTH_ENDPOINTS, type CaptchaConfig, readCaptchaConfig } from './captcha.js'
|
||||
|
||||
const COMPLETE_CONFIG = {
|
||||
[CAPTCHA_ENABLED_KEY]: 'true',
|
||||
@@ -14,7 +15,7 @@ const COMPLETE_CONFIG = {
|
||||
[CAPTCHA_SECRET_OPTION_KEY]: 'secret-key',
|
||||
}
|
||||
|
||||
describe('captcha service', () => {
|
||||
describe('captcha config', () => {
|
||||
it('treats absent settings as disabled', () => {
|
||||
expect(readCaptchaConfig({})).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
CAPTCHA_ENABLED_KEY,
|
||||
CAPTCHA_MIN_SCORE_KEY,
|
||||
CAPTCHA_PROVIDER_KEY,
|
||||
CAPTCHA_PROVIDERS,
|
||||
CAPTCHA_SECRET_OPTION_KEY,
|
||||
CAPTCHA_SITE_KEY_KEY,
|
||||
type CaptchaProvider,
|
||||
DEFAULT_CAPTCHA_PROVIDER,
|
||||
} from '@shared/captcha'
|
||||
|
||||
export const CAPTCHA_AUTH_ENDPOINTS = ['/sign-up/email', '/sign-in/email', '/sign-in/username'] as const
|
||||
|
||||
export type CaptchaConfig = {
|
||||
enabled: boolean
|
||||
provider: CaptchaProvider
|
||||
siteKey: string
|
||||
secretKey: string
|
||||
minScore?: number
|
||||
}
|
||||
|
||||
export type CaptchaOptionValues = Partial<Record<string, string>>
|
||||
|
||||
export function isCaptchaProvider(value: string): value is CaptchaProvider {
|
||||
return (CAPTCHA_PROVIDERS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
export function readCaptchaConfig(options: CaptchaOptionValues): CaptchaConfig | null {
|
||||
if (options[CAPTCHA_ENABLED_KEY] !== 'true') return null
|
||||
|
||||
const provider = options[CAPTCHA_PROVIDER_KEY] ?? DEFAULT_CAPTCHA_PROVIDER
|
||||
if (!isCaptchaProvider(provider)) throw new Error('Captcha provider is invalid')
|
||||
|
||||
const siteKey = options[CAPTCHA_SITE_KEY_KEY]?.trim() ?? ''
|
||||
if (!siteKey) throw new Error('Captcha site key is required before enabling captcha')
|
||||
|
||||
const secretKey = options[CAPTCHA_SECRET_OPTION_KEY]?.trim() ?? ''
|
||||
if (!secretKey) throw new Error('Captcha secret key is required before enabling captcha')
|
||||
|
||||
const minScore = readMinScore(options[CAPTCHA_MIN_SCORE_KEY])
|
||||
return { enabled: true, provider, siteKey, secretKey, minScore }
|
||||
}
|
||||
|
||||
function readMinScore(raw: string | undefined): number | undefined {
|
||||
const value = raw?.trim()
|
||||
if (!value) return undefined
|
||||
const score = Number(value)
|
||||
if (!Number.isFinite(score) || score < 0 || score > 1) {
|
||||
throw new Error('Captcha minimum score must be between 0 and 1')
|
||||
}
|
||||
return score
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
const PATH_PATTERN = /^[a-zA-Z0-9._/-]+$/
|
||||
const MAX_DEPTH = 5
|
||||
const MAX_PATH_LENGTH = 256
|
||||
|
||||
export type PathValidationError = { error: 'invalid path'; detail: string }
|
||||
|
||||
export function validatePath(path: string): PathValidationError | null {
|
||||
if (!PATH_PATTERN.test(path)) {
|
||||
return { error: 'invalid path', detail: 'path contains invalid characters' }
|
||||
}
|
||||
if (path.startsWith('/')) {
|
||||
return { error: 'invalid path', detail: 'path must not start with /' }
|
||||
}
|
||||
if (path.endsWith('/')) {
|
||||
return { error: 'invalid path', detail: 'path must not end with /' }
|
||||
}
|
||||
if (path.includes('..')) {
|
||||
return { error: 'invalid path', detail: 'path must not contain ..' }
|
||||
}
|
||||
if (path.includes('//')) {
|
||||
return { error: 'invalid path', detail: 'path must not contain //' }
|
||||
}
|
||||
if (path.length > MAX_PATH_LENGTH) {
|
||||
return { error: 'invalid path', detail: `path exceeds ${MAX_PATH_LENGTH} characters` }
|
||||
}
|
||||
const depth = path.split('/').length
|
||||
if (depth > MAX_DEPTH) {
|
||||
return { error: 'invalid path', detail: `path depth exceeds ${MAX_DEPTH} segments` }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export interface ImageUrlConfig {
|
||||
customDomain: string | null
|
||||
domainVerifiedAt: Date | null
|
||||
}
|
||||
|
||||
export function buildImageUrl(config: ImageUrlConfig | null, path: string, tokenUrl: string): string {
|
||||
if (config?.customDomain && config.domainVerifiedAt) {
|
||||
return `https://${config.customDomain}/${path}`
|
||||
}
|
||||
return tokenUrl
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { getTrustedPublicKeys, PUBLIC_KEYS, registerEnvPublicKeys } from './public-keys'
|
||||
import { getTrustedPublicKeys, PUBLIC_KEYS, registerEnvPublicKeys } from './license-keys'
|
||||
|
||||
describe('public keys', () => {
|
||||
afterEach(() => {
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BindingState } from '../../shared/types'
|
||||
import { hasFeature } from './has-feature'
|
||||
import { hasFeature } from './licensing'
|
||||
|
||||
describe('hasFeature', () => {
|
||||
it('returns false when state is null', () => {
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PRO_GATE_KEYS } from '@shared/feature-registry'
|
||||
import type { BindingState, LicenseFeature } from '@shared/types'
|
||||
|
||||
const BUSINESS_ONLY_FEATURES = new Set<LicenseFeature>(['quota_store', 'site_announcements'])
|
||||
|
||||
export function effectiveFeatures(edition: BindingState['edition']): LicenseFeature[] {
|
||||
if (edition === 'pro') return PRO_GATE_KEYS.filter((feature) => !BUSINESS_ONLY_FEATURES.has(feature))
|
||||
if (edition === 'business') return [...PRO_GATE_KEYS]
|
||||
return []
|
||||
}
|
||||
|
||||
export function hasFeature(feature: LicenseFeature, state: BindingState | null): boolean {
|
||||
return Boolean(feature && state?.bound && state.active && effectiveFeatures(state.edition).includes(feature))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Windows-style auto-rename: "report.pdf" → "report (1).pdf", then " (2)", ...
|
||||
// For folders or dot-prefixed names the suffix is appended to the whole name.
|
||||
// Pure: no I/O, the repo drives the availability search around this.
|
||||
export function suggestRenamed(name: string, index: number): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
if (dot <= 0) return `${name} (${index})`
|
||||
return `${name.slice(0, dot)} (${index})${name.slice(dot)}`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// The org's traffic accounting period, `YYYY-MM` in UTC. Pure.
|
||||
export function currentTrafficPeriod(now = new Date()): string {
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${now.getUTCFullYear()}-${month}`
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Pure share access rules. No I/O, no frameworks.
|
||||
|
||||
export function isAccessibleByUser(recipients: Array<{ recipientUserId: string | null }>, userId: string): boolean {
|
||||
return recipients.some((r) => r.recipientUserId === userId)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export const SITE_PUBLIC_ORIGIN_KEY = 'site_public_origin'
|
||||
|
||||
export function originFromRequestUrl(requestUrl: string): string | null {
|
||||
try {
|
||||
const url = new URL(requestUrl)
|
||||
return normalizePublicOrigin(url.origin)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePublicOrigin(value: string | undefined | null): string | null {
|
||||
const input = value?.trim()
|
||||
if (!input) return null
|
||||
|
||||
try {
|
||||
const url = new URL(input)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
return url.origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
import { DirType } from '../../shared/constants'
|
||||
import type { Matter } from './matter'
|
||||
import type { WebDavWorkspace } from './webdav-path'
|
||||
import { matterHref, workspaceHref } from './webdav-path'
|
||||
import type { DavDeadProperty, DavLock, DavPropertyName } from './webdav-state'
|
||||
import { DirType } from '@shared/constants'
|
||||
import {
|
||||
DAV_NAMESPACE,
|
||||
type DavDeadProperty,
|
||||
type DavLock,
|
||||
type DavPropertyName,
|
||||
davEtag,
|
||||
matterHref,
|
||||
type WebDavMatter,
|
||||
type WebDavWorkspace,
|
||||
workspaceHref,
|
||||
} from './webdav'
|
||||
|
||||
export const DAV_NAMESPACE = 'DAV:'
|
||||
export { DAV_NAMESPACE, davEtag }
|
||||
|
||||
export interface DavEntry {
|
||||
href: string
|
||||
@@ -71,7 +78,7 @@ export function mountRootEntry(): DavEntry {
|
||||
|
||||
export function matterEntry(
|
||||
workspace: WebDavWorkspace,
|
||||
matter: Matter,
|
||||
matter: WebDavMatter,
|
||||
deadProperties: DavDeadProperty[],
|
||||
locks: DavLock[],
|
||||
): DavEntry {
|
||||
@@ -183,10 +190,6 @@ export function parseLockInfoXml(body: string): LockInfoRequest {
|
||||
return { owner }
|
||||
}
|
||||
|
||||
export function davEtag(id: string, size: number, updatedAt: Date): string {
|
||||
return `"${id}-${size}-${updatedAt.getTime()}"`
|
||||
}
|
||||
|
||||
export function xmlResponse(body: string, status: number, headers?: Record<string, string>): Response {
|
||||
return new Response(body, {
|
||||
status,
|
||||
@@ -0,0 +1,59 @@
|
||||
export const DAV_NAMESPACE = 'DAV:'
|
||||
|
||||
export interface DavPropertyName {
|
||||
namespace: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface DavDeadProperty extends DavPropertyName {
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface DavLock {
|
||||
id: string
|
||||
token: string
|
||||
orgId: string
|
||||
resourcePath: string
|
||||
owner: string
|
||||
depth: string
|
||||
expiresAt: Date
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export interface WebDavWorkspace {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
href: string
|
||||
}
|
||||
|
||||
// The matter fields the WebDAV href/etag/entry helpers read. A full matter row
|
||||
// is structurally assignable, so http handlers pass their rows directly.
|
||||
export interface WebDavMatter {
|
||||
id: string
|
||||
name: string
|
||||
parent: string
|
||||
type: string
|
||||
size: number | null
|
||||
dirtype: number | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
export function joinMatterPath(parent: string, name: string): string {
|
||||
return parent ? `${parent}/${name}` : name
|
||||
}
|
||||
|
||||
export function matterHref(workspace: WebDavWorkspace, matter: Pick<WebDavMatter, 'parent' | 'name'>): string {
|
||||
const path = joinMatterPath(matter.parent, matter.name)
|
||||
return `/dav/${encodeURIComponent(workspace.slug)}/${path.split('/').map(encodeURIComponent).join('/')}`
|
||||
}
|
||||
|
||||
export function workspaceHref(workspace: WebDavWorkspace): string {
|
||||
return `/dav/${encodeURIComponent(workspace.slug)}/`
|
||||
}
|
||||
|
||||
export function davEtag(id: string, size: number, updatedAt: Date): string {
|
||||
return `"${id}-${size}-${updatedAt.getTime()}"`
|
||||
}
|
||||
+19
-21
@@ -5,18 +5,19 @@ import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { resolveAppCommit, resolveAppVersion } from '../scripts/app-version.mjs'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
|
||||
import { createQuotaRepo } from './adapters/repos/quota'
|
||||
import { createBootstrap } from './bootstrap'
|
||||
import { buildCloudInstanceInfo, runtimeInfo } from './licensing/instance-info'
|
||||
import { createDeps } from './composition'
|
||||
import { createLibsqlPlatform } from './platform/libsql'
|
||||
import { createNodePlatform } from './platform/node'
|
||||
import { type DeployPlatform, setDeployPlatform } from './runtime-platform'
|
||||
import { syncPendingCloudTrafficReports } from './services/cloud-traffic-metering'
|
||||
import { resetExpiredTrafficQuotas } from './services/effective-quota'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/instance-telemetry'
|
||||
import { runLicensingRefresh } from './services/licensing-refresh-runner'
|
||||
import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage'
|
||||
import { getSitePublicOrigin } from './services/site-public-origin'
|
||||
import { purgeExpiredTrash, resolveTrashRetentionDays } from './services/trash-retention'
|
||||
import { syncPendingCloudTrafficReports } from './usecases/cloud-traffic-metering'
|
||||
import { buildCloudInstanceInfo, runtimeInfo } from './usecases/instance-info'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './usecases/instance-telemetry'
|
||||
import { runLicensingRefresh } from './usecases/licensing-refresh-runner'
|
||||
import { syncPendingRemoteDownloadUsageReports } from './usecases/remote-download-usage'
|
||||
import { getSitePublicOrigin } from './usecases/site-public-origin'
|
||||
import { purgeExpiredTrash, resolveTrashRetentionDays } from './usecases/trash-retention'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
||||
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
@@ -55,6 +56,7 @@ const platform = process.env.TURSO_DATABASE_URL
|
||||
})
|
||||
: createNodePlatform()
|
||||
|
||||
const deps = createDeps(platform)
|
||||
const app = await createBootstrap(platform)
|
||||
|
||||
const server = new Hono()
|
||||
@@ -81,21 +83,21 @@ console.log('licensing.refresh.scheduler.started interval=6h')
|
||||
setInterval(() => {
|
||||
// runLicensingRefresh handles all errors internally and never rejects.
|
||||
void (async () => {
|
||||
const instanceUrl = await getSitePublicOrigin(platform.db)
|
||||
const instanceUrl = await getSitePublicOrigin(deps)
|
||||
const instance = instanceUrl
|
||||
? await buildCloudInstanceInfo(platform.db, {
|
||||
? await buildCloudInstanceInfo(deps, {
|
||||
url: instanceUrl,
|
||||
runtime: runtimeInfo(platform),
|
||||
})
|
||||
: undefined
|
||||
await runLicensingRefresh(platform.db, cloudBaseUrl, instance)
|
||||
await runLicensingRefresh(deps, cloudBaseUrl, instance)
|
||||
})()
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
|
||||
console.log('traffic.sync.scheduler.started interval=10m')
|
||||
setInterval(() => {
|
||||
void syncPendingCloudTrafficReports({ db: platform.db, cloudBaseUrl })
|
||||
void syncPendingRemoteDownloadUsageReports({ db: platform.db, cloudBaseUrl })
|
||||
void syncPendingCloudTrafficReports(deps, { cloudBaseUrl })
|
||||
void syncPendingRemoteDownloadUsageReports(deps, { cloudBaseUrl })
|
||||
}, TRAFFIC_SYNC_INTERVAL_MS)
|
||||
|
||||
function reportNodeInstanceTelemetry(): void {
|
||||
@@ -103,8 +105,7 @@ function reportNodeInstanceTelemetry(): void {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
await reportInstanceTelemetry({
|
||||
db: platform.db,
|
||||
await reportInstanceTelemetry(deps, {
|
||||
config: {
|
||||
allowIp: envAllowsIp(process.env.ZPAN_TELEMETRY_ALLOW_IP),
|
||||
},
|
||||
@@ -132,19 +133,16 @@ setInterval(reportNodeInstanceTelemetry, INSTANCE_TELEMETRY_INTERVAL_MS)
|
||||
|
||||
console.log('quota.reset.scheduler.started interval=24h')
|
||||
// Run once at boot to catch a month boundary crossed while the server was down.
|
||||
void resetExpiredTrafficQuotas(platform.db)
|
||||
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
|
||||
setInterval(() => {
|
||||
void resetExpiredTrafficQuotas(platform.db)
|
||||
void createQuotaRepo(platform.db).resetExpiredTrafficQuotas()
|
||||
}, QUOTA_RESET_INTERVAL_MS)
|
||||
|
||||
console.log('trash.purge.scheduler.started interval=24h')
|
||||
function purgeExpiredTrashJob(): void {
|
||||
void (async () => {
|
||||
try {
|
||||
const purged = await purgeExpiredTrash(
|
||||
platform.db,
|
||||
resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS),
|
||||
)
|
||||
const purged = await purgeExpiredTrash(deps, resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS))
|
||||
if (purged > 0) console.log(`trash.purge.done count=${purged}`)
|
||||
} catch (err) {
|
||||
console.error(`trash.purge.error code=${err instanceof Error ? err.message : String(err)}`)
|
||||
|
||||
+6
-6
@@ -28,7 +28,7 @@ describe('Admin Announcements API', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for non-admin users', async () => {
|
||||
it('returns 403 for non-admin users [spec: announcements/admin-only]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
await adminHeaders(app)
|
||||
const headers = await authedHeaders(app, 'user@example.com')
|
||||
@@ -47,7 +47,7 @@ describe('Admin Announcements API', () => {
|
||||
expect(body.feature).toBe('site_announcements')
|
||||
})
|
||||
|
||||
it('creates, lists, updates, and deletes an announcement', async () => {
|
||||
it('creates, lists, updates, and deletes an announcement [spec: announcements/crud]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
await seedBusinessLicense(db)
|
||||
@@ -100,7 +100,7 @@ describe('User Announcements API', () => {
|
||||
expect(body.feature).toBe('site_announcements')
|
||||
})
|
||||
|
||||
it('returns active announcements', async () => {
|
||||
it('returns active announcements [spec: announcements/user-active]', async () => {
|
||||
const ctx = await createTestApp()
|
||||
const { app } = ctx
|
||||
const created = await createPublishedAnnouncement(ctx)
|
||||
@@ -113,7 +113,7 @@ describe('User Announcements API', () => {
|
||||
expect(active.items[0].id).toBe(created.id)
|
||||
})
|
||||
|
||||
it('keeps archived announcements in history but not active list', async () => {
|
||||
it('keeps archived announcements in history but not active list [spec: announcements/archived-history]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
await seedBusinessLicense(db)
|
||||
@@ -142,7 +142,7 @@ describe('User Announcements API', () => {
|
||||
expect(history.items[0]).toMatchObject({ id: created.id, status: 'archived' })
|
||||
})
|
||||
|
||||
it('does not include draft announcements in history', async () => {
|
||||
it('does not include draft announcements in history [spec: announcements/no-drafts]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
await seedBusinessLicense(db)
|
||||
@@ -159,7 +159,7 @@ describe('User Announcements API', () => {
|
||||
expect(body.total).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects invalid pagination query values', async () => {
|
||||
it('rejects invalid pagination query values [spec: announcements/pagination-validation]', async () => {
|
||||
const ctx = await createTestApp()
|
||||
const { app } = ctx
|
||||
await createPublishedAnnouncement(ctx)
|
||||
@@ -8,14 +8,6 @@ import {
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { requireFeature } from '../middleware/require-feature'
|
||||
import {
|
||||
createAnnouncement,
|
||||
deleteAnnouncement,
|
||||
getAnnouncement,
|
||||
listAdminAnnouncements,
|
||||
listUserAnnouncements,
|
||||
updateAnnouncement,
|
||||
} from '../services/announcement'
|
||||
|
||||
function pagination(query: { page?: string; pageSize?: string }) {
|
||||
return {
|
||||
@@ -28,9 +20,8 @@ export const announcements = new Hono<Env>()
|
||||
.use(requireAuth)
|
||||
.use(requireFeature('site_announcements'))
|
||||
.get('/', zValidator('query', listAnnouncementsQuerySchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const query = c.req.valid('query')
|
||||
const result = await listUserAnnouncements(db, {
|
||||
const result = await c.get('deps').announcements.listUser({
|
||||
activeOnly: query.scope === 'active',
|
||||
...pagination(query),
|
||||
})
|
||||
@@ -41,35 +32,30 @@ export const adminAnnouncements = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
.use(requireFeature('site_announcements'))
|
||||
.get('/', zValidator('query', listAdminAnnouncementsQuerySchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const query = c.req.valid('query')
|
||||
const result = await listAdminAnnouncements(db, { status: query.status, ...pagination(query) })
|
||||
const result = await c.get('deps').announcements.listAdmin({ status: query.status, ...pagination(query) })
|
||||
return c.json(result)
|
||||
})
|
||||
.post('/', zValidator('json', announcementInputSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const userId = c.get('userId')!
|
||||
const announcement = await createAnnouncement(db, c.req.valid('json'), userId)
|
||||
const announcement = await c.get('deps').announcements.create(c.req.valid('json'), userId)
|
||||
return c.json(announcement, 201)
|
||||
})
|
||||
.get('/:id', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const announcement = await getAnnouncement(db, id)
|
||||
const announcement = await c.get('deps').announcements.get(id)
|
||||
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
|
||||
return c.json(announcement)
|
||||
})
|
||||
.put('/:id', zValidator('json', announcementInputSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const announcement = await updateAnnouncement(db, id, c.req.valid('json'))
|
||||
const announcement = await c.get('deps').announcements.update(id, c.req.valid('json'))
|
||||
if (!announcement) return c.json({ error: 'Announcement not found' }, 404)
|
||||
return c.json(announcement)
|
||||
})
|
||||
.delete('/:id', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const id = c.req.param('id')
|
||||
const deleted = await deleteAnnouncement(db, id)
|
||||
const deleted = await c.get('deps').announcements.delete(id)
|
||||
if (!deleted) return c.json({ error: 'Announcement not found' }, 404)
|
||||
return c.json({ id, deleted: true })
|
||||
})
|
||||
+4
-4
@@ -6,9 +6,9 @@
|
||||
*/
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { S3Service } from '../adapters/gateways/s3.js'
|
||||
import { createShareRepo } from '../adapters/repos/share'
|
||||
import { activityEvents } from '../db/schema.js'
|
||||
import { S3Service } from '../services/s3.js'
|
||||
import { createShare } from '../services/share.js'
|
||||
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
@@ -682,7 +682,7 @@ describe('Share download audit events', () => {
|
||||
const orgId = await getPersonalOrgId(db)
|
||||
const creatorId = (await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`))[0].id
|
||||
await insertFile(db, orgId, { id: 'dl-audit-1', name: 'report.pdf' })
|
||||
const share = await createShare(db, { matterId: 'dl-audit-1', orgId, creatorId, kind: 'landing' })
|
||||
const share = await createShareRepo(db).create({ matterId: 'dl-audit-1', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
// Fetch rootRef from share metadata
|
||||
const metaRes = await app.request(`/api/shares/${share.token}`)
|
||||
@@ -710,7 +710,7 @@ describe('Share download audit events', () => {
|
||||
const orgId = await getPersonalOrgId(db)
|
||||
const creatorId = (await db.all<{ id: string }>(sql`SELECT id FROM user LIMIT 1`))[0].id
|
||||
await insertFile(db, orgId, { id: 'dl-audit-2', name: 'anon.pdf' })
|
||||
const share = await createShare(db, { matterId: 'dl-audit-2', orgId, creatorId, kind: 'landing' })
|
||||
const share = await createShareRepo(db).create({ matterId: 'dl-audit-2', orgId, creatorId, kind: 'landing' })
|
||||
|
||||
const metaRes = await app.request(`/api/shares/${share.token}`)
|
||||
const meta = (await metaRes.json()) as { rootRef: string }
|
||||
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'
|
||||
import { adminHeaders, authedHeaders, createTestApp, seedProLicense } from '../test/setup.js'
|
||||
|
||||
describe('GET /api/admin/audit — auth guards', () => {
|
||||
it('returns 401 without auth', async () => {
|
||||
it('returns 401 without auth [spec: audit/auth-required]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const res = await app.request('/api/admin/audit')
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for authenticated non-admin', async () => {
|
||||
it('returns 403 for authenticated non-admin [spec: audit/admin-only]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
await adminHeaders(app)
|
||||
const headers = await authedHeaders(app, 'user@example.com')
|
||||
@@ -16,7 +16,7 @@ describe('GET /api/admin/audit — auth guards', () => {
|
||||
expect(res.status).toBe(403)
|
||||
})
|
||||
|
||||
it('returns 402 when admin lacks audit_log feature', async () => {
|
||||
it('returns 402 when admin lacks audit_log feature [spec: audit/feature-gated]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await adminHeaders(app)
|
||||
// No Pro license seeded — feature gate should block
|
||||
@@ -28,7 +28,7 @@ describe('GET /api/admin/audit — auth guards', () => {
|
||||
})
|
||||
|
||||
describe('GET /api/admin/audit — licensed admin', () => {
|
||||
it('returns empty list when no events exist', async () => {
|
||||
it('returns empty list when no events exist [spec: audit/empty]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -42,7 +42,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.pageSize).toBe(20)
|
||||
})
|
||||
|
||||
it('lists events across multiple orgs, newest first', async () => {
|
||||
it('lists events across multiple orgs, newest first [spec: audit/list-newest-first]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -85,7 +85,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items[1].id).toBe('evt-a')
|
||||
})
|
||||
|
||||
it('filters by orgId', async () => {
|
||||
it('filters by orgId [spec: audit/filter-org]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -123,7 +123,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items[0].orgId).toBe('org-x')
|
||||
})
|
||||
|
||||
it('filters by userId', async () => {
|
||||
it('filters by userId [spec: audit/filter-user]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -160,7 +160,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items[0].userId).toBe('alice')
|
||||
})
|
||||
|
||||
it('filters by action', async () => {
|
||||
it('filters by action [spec: audit/filter-action]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -197,7 +197,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items[0].action).toBe('upload')
|
||||
})
|
||||
|
||||
it('filters by targetType', async () => {
|
||||
it('filters by targetType [spec: audit/filter-target-type]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -234,7 +234,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items[0].targetType).toBe('folder')
|
||||
})
|
||||
|
||||
it('respects pagination params and returns correct page/pageSize', async () => {
|
||||
it('respects pagination params and returns correct page/pageSize [spec: audit/pagination]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -268,7 +268,7 @@ describe('GET /api/admin/audit — licensed admin', () => {
|
||||
expect(body.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('response items include actor display info and orgName', async () => {
|
||||
it('response items include actor display info and orgName [spec: audit/actor-info]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await seedProLicense(db)
|
||||
const headers = await adminHeaders(app)
|
||||
@@ -4,18 +4,16 @@ import { listAdminAuditQuerySchema } from '../../shared/schemas'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { requireFeature } from '../middleware/require-feature'
|
||||
import { listAdminAuditEvents } from '../services/activity'
|
||||
|
||||
export const adminAudit = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
.use(requireFeature('audit_log'))
|
||||
.get('/', zValidator('query', listAdminAuditQuerySchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const query = c.req.valid('query')
|
||||
const page = Math.max(1, Number(query.page ?? '1'))
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? '20')))
|
||||
|
||||
const result = await listAdminAuditEvents(db, {
|
||||
const result = await c.get('deps').activity.listAdminAudit({
|
||||
page,
|
||||
pageSize,
|
||||
orgId: query.orgId,
|
||||
+16
-16
@@ -39,7 +39,7 @@ describe('Auth Providers — public list', () => {
|
||||
expect(body.items).toEqual([])
|
||||
})
|
||||
|
||||
it('returns only enabled providers', async () => {
|
||||
it('returns only enabled providers [spec: auth-providers/public-enabled-only]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
await seedProLicense(db) // 2nd provider requires social_login_unlimited
|
||||
@@ -54,7 +54,7 @@ describe('Auth Providers — public list', () => {
|
||||
expect(body.items[0].providerId).toBe('github')
|
||||
})
|
||||
|
||||
it('does not include clientSecret in public response', async () => {
|
||||
it('does not include clientSecret in public response [spec: auth-providers/public-no-secret]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('Auth Providers — public list', () => {
|
||||
expect(body.items[0]).not.toHaveProperty('clientSecret')
|
||||
})
|
||||
|
||||
it('returns display name and icon from provider metadata', async () => {
|
||||
it('returns display name and icon from provider metadata [spec: auth-providers/metadata]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('Auth Providers — public list', () => {
|
||||
expect(body.items[0].icon).toBe('github')
|
||||
})
|
||||
|
||||
it('uses providerId as fallback name and icon for unknown OIDC provider', async () => {
|
||||
it('uses providerId as fallback name and icon for unknown OIDC provider [spec: auth-providers/oidc-fallback]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -110,7 +110,7 @@ describe('Auth Providers — admin list', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('returns 403 for non-admin user', async () => {
|
||||
it('returns 403 for non-admin user [spec: auth-providers/admin-only]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
// First sign-up makes admin; second is regular user
|
||||
await adminHeaders(app)
|
||||
@@ -134,7 +134,7 @@ describe('Auth Providers — admin list', () => {
|
||||
expect(body.items).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all configs including disabled providers', async () => {
|
||||
it('returns all configs including disabled providers [spec: auth-providers/admin-list-all]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
await seedProLicense(db) // 2nd provider requires social_login_unlimited
|
||||
@@ -147,7 +147,7 @@ describe('Auth Providers — admin list', () => {
|
||||
expect(body.items).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('masks clientSecret leaving only last 4 chars visible', async () => {
|
||||
it('masks clientSecret leaving only last 4 chars visible [spec: auth-providers/mask-secret]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('Auth Providers — admin list', () => {
|
||||
expect(secret).not.toBe(githubConfig.clientSecret)
|
||||
})
|
||||
|
||||
it('masks short secret entirely with four asterisks', async () => {
|
||||
it('masks short secret entirely with four asterisks [spec: auth-providers/mask-short-secret]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -183,7 +183,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
it('admin can create a builtin provider', async () => {
|
||||
it('admin can create a builtin provider [spec: auth-providers/create-builtin]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(body.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks the second provider on the free plan with 402', async () => {
|
||||
it('blocks the second provider on the free plan with 402 [spec: auth-providers/free-limit]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -210,7 +210,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(body.limit).toBe(1)
|
||||
})
|
||||
|
||||
it('allows additional providers with the social_login_unlimited entitlement', async () => {
|
||||
it('allows additional providers with the social_login_unlimited entitlement [spec: auth-providers/unlimited-entitlement]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
await seedProLicense(db)
|
||||
@@ -219,7 +219,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect((await putProvider(app, admin, 'google', { ...githubConfig, clientId: 'google-id' })).status).toBe(200)
|
||||
})
|
||||
|
||||
it('updating the only provider is not blocked by the free limit', async () => {
|
||||
it('updating the only provider is not blocked by the free limit [spec: auth-providers/update-not-limited]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect((body.clientSecret as string).endsWith('alue')).toBe(true)
|
||||
})
|
||||
|
||||
it('updates an existing provider on second PUT', async () => {
|
||||
it('updates an existing provider on second PUT [spec: auth-providers/update]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(listBody.items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('admin can create an OIDC provider with discoveryUrl', async () => {
|
||||
it('admin can create an OIDC provider with discoveryUrl [spec: auth-providers/create-oidc]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -268,7 +268,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(body.scopes).toEqual(oidcConfig.scopes)
|
||||
})
|
||||
|
||||
it('returns 400 for unknown builtin provider ID', async () => {
|
||||
it('returns 400 for unknown builtin provider ID [spec: auth-providers/unknown-builtin]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('Auth Providers — admin upsert (PUT)', () => {
|
||||
expect(body.error).toMatch(/Unknown builtin provider/)
|
||||
})
|
||||
|
||||
it('returns 400 for OIDC provider missing discoveryUrl', async () => {
|
||||
it('returns 400 for OIDC provider missing discoveryUrl [spec: auth-providers/oidc-missing-discovery]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const admin = await adminHeaders(app)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { zValidator } from '@hono/zod-validator'
|
||||
import { eq, like } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
import { z } from 'zod'
|
||||
import { FREE_SOCIAL_LOGIN_LIMIT } from '../../shared/constants'
|
||||
@@ -11,10 +10,10 @@ import {
|
||||
OAuthProviderMeta,
|
||||
parseProviderConfig,
|
||||
} from '../../shared/oauth-providers'
|
||||
import { systemOptions } from '../db/schema'
|
||||
import { hasFeature, loadBindingState } from '../licensing/has-feature'
|
||||
import { hasFeature } from '../domain/licensing'
|
||||
import { requireAdmin } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { loadBindingState } from '../usecases/licensing'
|
||||
|
||||
function optionKey(providerId: string): string {
|
||||
return `${OAUTH_PROVIDER_KEY_PREFIX}${providerId}`
|
||||
@@ -36,8 +35,7 @@ const upsertSchema = z.object({
|
||||
|
||||
// Public: enabled providers only, no secrets (for login page buttons)
|
||||
export const publicAuthProviders = new Hono<Env>().get('/', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
|
||||
const rows = await c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN)
|
||||
const items = rows
|
||||
.map((r) => {
|
||||
const config = parseProviderConfig(r.value)
|
||||
@@ -58,8 +56,7 @@ export const publicAuthProviders = new Hono<Env>().get('/', async (c) => {
|
||||
export const adminAuthProviders = new Hono<Env>()
|
||||
.use(requireAdmin)
|
||||
.get('/', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const rows = await db.select().from(systemOptions).where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN))
|
||||
const rows = await c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN)
|
||||
const items = rows
|
||||
.map((r) => {
|
||||
const config = parseProviderConfig(r.value)
|
||||
@@ -70,7 +67,6 @@ export const adminAuthProviders = new Hono<Env>()
|
||||
return c.json({ items })
|
||||
})
|
||||
.put('/:providerId', zValidator('json', upsertSchema), async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const providerId = c.req.param('providerId')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
@@ -88,16 +84,13 @@ export const adminAuthProviders = new Hono<Env>()
|
||||
const key = optionKey(providerId)
|
||||
const value = JSON.stringify(config)
|
||||
|
||||
const existing = await db.select({ key: systemOptions.key }).from(systemOptions).where(eq(systemOptions.key, key))
|
||||
if (existing.length > 0) {
|
||||
await db.update(systemOptions).set({ value, public: false }).where(eq(systemOptions.key, key))
|
||||
const existing = await c.get('deps').systemOptions.get(key)
|
||||
if (existing) {
|
||||
await c.get('deps').systemOptions.set(key, value, false)
|
||||
} else {
|
||||
const [configured, state] = await Promise.all([
|
||||
db
|
||||
.select({ key: systemOptions.key })
|
||||
.from(systemOptions)
|
||||
.where(like(systemOptions.key, OAUTH_PROVIDER_KEY_PATTERN)),
|
||||
loadBindingState(db),
|
||||
c.get('deps').systemOptions.listByKeyLike(OAUTH_PROVIDER_KEY_PATTERN),
|
||||
loadBindingState(c.get('deps')),
|
||||
])
|
||||
if (!hasFeature('social_login_unlimited', state) && configured.length >= FREE_SOCIAL_LOGIN_LIMIT) {
|
||||
return c.json(
|
||||
@@ -111,17 +104,16 @@ export const adminAuthProviders = new Hono<Env>()
|
||||
402,
|
||||
)
|
||||
}
|
||||
await db.insert(systemOptions).values({ key, value, public: false })
|
||||
await c.get('deps').systemOptions.set(key, value, false)
|
||||
}
|
||||
|
||||
return c.json({ ...config, clientSecret: maskSecret(config.clientSecret) })
|
||||
})
|
||||
.delete('/:providerId', async (c) => {
|
||||
const db = c.get('platform').db
|
||||
const providerId = c.req.param('providerId')
|
||||
if (!isValidProviderId(providerId)) {
|
||||
return c.json({ error: 'Provider ID must contain only lowercase letters, numbers, and hyphens' }, 400)
|
||||
}
|
||||
await db.delete(systemOptions).where(eq(systemOptions.key, optionKey(providerId)))
|
||||
await c.get('deps').systemOptions.delete(optionKey(providerId))
|
||||
return c.json({ providerId, deleted: true })
|
||||
})
|
||||
+4
-4
@@ -23,7 +23,7 @@ describe('migration 0004_username_plugin.sql', () => {
|
||||
})
|
||||
|
||||
describe('username plugin — sign-up with username', () => {
|
||||
it('sign-up with username stores the username on the user record', async () => {
|
||||
it('sign-up with username stores the username on the user record [spec: auth-username/signup-with-username]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
@@ -39,7 +39,7 @@ describe('username plugin — sign-up with username', () => {
|
||||
expect(users[0].username).toBe('alice42')
|
||||
})
|
||||
|
||||
it('sign-up without username generates a username from the email prefix', async () => {
|
||||
it('sign-up without username generates a username from the email prefix [spec: auth-username/signup-generates-username]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
@@ -50,7 +50,7 @@ describe('username plugin — sign-up with username', () => {
|
||||
expect(users[0].username).toBe('bob')
|
||||
})
|
||||
|
||||
it('sign-up with duplicate username returns a non-200 response', async () => {
|
||||
it('sign-up with duplicate username returns a non-200 response [spec: auth-username/duplicate-rejected]', async () => {
|
||||
const { app } = await createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
@@ -75,7 +75,7 @@ describe('username plugin — sign-up with username', () => {
|
||||
expect(res.status).not.toBe(200)
|
||||
})
|
||||
|
||||
it('two users with different usernames both register successfully', async () => {
|
||||
it('two users with different usernames both register successfully [spec: auth-username/distinct-usernames]', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const res1 = await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user