diff --git a/apps/api/__tests__/openapi-paths.test.ts b/apps/api/__tests__/openapi-paths.test.ts index 6762e123..7b1cf331 100644 --- a/apps/api/__tests__/openapi-paths.test.ts +++ b/apps/api/__tests__/openapi-paths.test.ts @@ -7,11 +7,13 @@ const expectedPaths = [ "/media/create", "/media/delete/{mediaId}", "/media/get", + "/media/get/assets", "/media/get/count", "/media/get/size", "/media/get/{mediaId}", "/media/seal/{mediaId}", "/media/signature/create", + "/media/timeline", "/settings/media/create", "/settings/media/get", ]; diff --git a/apps/api/drizzle/0009_media_assets_timeline.sql b/apps/api/drizzle/0009_media_assets_timeline.sql new file mode 100644 index 00000000..c90da771 --- /dev/null +++ b/apps/api/drizzle/0009_media_assets_timeline.sql @@ -0,0 +1,10 @@ +ALTER TABLE "media" ADD COLUMN "asset_id" text;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "taken_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "width" bigint;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "height" bigint;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "duration_ms" bigint;--> statement-breakpoint +ALTER TABLE "media" ADD COLUMN "thumbnail_private" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "media_settings" ADD COLUMN "private_thumbnails" boolean;--> statement-breakpoint +ALTER TABLE "media_settings" ADD COLUMN "thumbnail_fit" text;--> statement-breakpoint +CREATE UNIQUE INDEX "media_app_asset_uidx" ON "media" USING btree ("app_id","asset_id") WHERE "media"."asset_id" is not null;--> statement-breakpoint +CREATE INDEX "media_timeline_idx" ON "media" USING btree ("app_id","temp",coalesce("taken_at", "created_at") desc,"id" desc); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0009_snapshot.json b/apps/api/drizzle/meta/0009_snapshot.json new file mode 100644 index 00000000..e490146b --- /dev/null +++ b/apps/api/drizzle/meta/0009_snapshot.json @@ -0,0 +1,4152 @@ +{ + "id": "955f698e-f039-4d5b-8259-28b913b7c032", + "prevId": "8de63584-4558-43d3-ad7d-7c37393a7a70", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "crv": { + "name": "crv", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_discovery_id": { + "name": "client_discovery_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "client_credentials_scopes": { + "name": "client_credentials_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_uri": { + "name": "backchannel_logout_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "backchannel_logout_session_required": { + "name": "backchannel_logout_session_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "application_type": { + "name": "application_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks": { + "name": "jwks", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "jwks_uri": { + "name": "jwks_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens": { + "name": "dpop_bound_access_tokens", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_assertion": { + "name": "oauth_client_assertion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client_resource": { + "name": "oauth_client_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_resource_client_id_oauth_client_client_id_fk": { + "name": "oauth_client_resource_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_client_resource_resource_id_oauth_resource_identifier_fk": { + "name": "oauth_client_resource_resource_id_oauth_resource_identifier_fk", + "tableFrom": "oauth_client_resource", + "tableTo": "oauth_resource", + "columnsFrom": [ + "resource_id" + ], + "columnsTo": [ + "identifier" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "authorization_code_id": { + "name": "authorization_code_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "requested_user_info_claims": { + "name": "requested_user_info_claims", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_response": { + "name": "rotation_replay_response", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rotation_replay_expires_at": { + "name": "rotation_replay_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmation": { + "name": "confirmation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_refresh_token_token_unique": { + "name": "oauth_refresh_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_resource": { + "name": "oauth_resource", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_ttl": { + "name": "access_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refresh_token_ttl": { + "name": "refresh_token_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "signing_algorithm": { + "name": "signing_algorithm", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signing_key_id": { + "name": "signing_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_scopes": { + "name": "allowed_scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "custom_claims": { + "name": "custom_claims", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "dpop_bound_access_tokens_required": { + "name": "dpop_bound_access_tokens_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "policy_version": { + "name": "policy_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_resource_identifier_unique": { + "name": "oauth_resource_identifier_unique", + "nullsNotDistinct": false, + "columns": [ + "identifier" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_type": { + "name": "created_by_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_keys_app_id_idx": { + "name": "api_keys_app_id_idx", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_app_id_apps_id_fk": { + "name": "api_keys_app_id_apps_id_fk", + "tableFrom": "api_keys", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_created_by_id_user_id_fk": { + "name": "api_keys_created_by_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_api_key_id_unique": { + "name": "api_keys_api_key_id_unique", + "nullsNotDistinct": false, + "columns": [ + "api_key_id" + ] + }, + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": { + "api_keys_created_by_type_check": { + "name": "api_keys_created_by_type_check", + "value": "\"api_keys\".\"created_by_type\" IN ('user', 'bootstrap', 'legacy')" + } + }, + "isRLSEnabled": false + }, + "public.apps": { + "name": "apps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "apps_user_name_uidx": { + "name": "apps_user_name_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"apps\".\"deleted\" = false", + "concurrently": false, + "method": "btree", + "with": {} + }, + "apps_one_default_uidx": { + "name": "apps_one_default_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"apps\".\"is_default\" = true AND \"apps\".\"deleted\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "apps_user_id_user_id_fk": { + "name": "apps_user_id_user_id_fk", + "tableFrom": "apps", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "apps_app_id_unique": { + "name": "apps_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_file_name": { + "name": "original_file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "thumbnail_generated": { + "name": "thumbnail_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "access_control": { + "name": "access_control", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temp": { + "name": "temp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "asset_id": { + "name": "asset_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "taken_at": { + "name": "taken_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "thumbnail_private": { + "name": "thumbnail_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "media_owner_idx": { + "name": "media_owner_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "temp", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_app_asset_uidx": { + "name": "media_app_asset_uidx", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"media\".\"asset_id\" is not null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_timeline_idx": { + "name": "media_timeline_idx", + "columns": [ + { + "expression": "app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "temp", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"taken_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" desc", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_user_id_user_id_fk": { + "name": "media_user_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_app_id_apps_id_fk": { + "name": "media_app_id_apps_id_fk", + "tableFrom": "media", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_media_id_unique": { + "name": "media_media_id_unique", + "nullsNotDistinct": false, + "columns": [ + "media_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media_settings": { + "name": "media_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "use_web_p": { + "name": "use_web_p", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "webp_output_quality": { + "name": "webp_output_quality", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "thumbnail_width": { + "name": "thumbnail_width", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "thumbnail_height": { + "name": "thumbnail_height", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "private_thumbnails": { + "name": "private_thumbnails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "thumbnail_fit": { + "name": "thumbnail_fit", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "media_settings_user_id_user_id_fk": { + "name": "media_settings_user_id_user_id_fk", + "tableFrom": "media_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_settings_app_id_apps_id_fk": { + "name": "media_settings_app_id_apps_id_fk", + "tableFrom": "media_settings", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_settings_app_id_unique": { + "name": "media_settings_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_app_selections": { + "name": "oauth_app_selections", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_app_selections_session_id_session_id_fk": { + "name": "oauth_app_selections_session_id_session_id_fk", + "tableFrom": "oauth_app_selections", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_app_selections_app_id_apps_id_fk": { + "name": "oauth_app_selections_app_id_apps_id_fk", + "tableFrom": "oauth_app_selections", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_user_id": { + "name": "public_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": { + "profiles_user_id_user_id_fk": { + "name": "profiles_user_id_user_id_fk", + "tableFrom": "profiles", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "profiles_public_user_id_unique": { + "name": "profiles_public_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "public_user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.signatures": { + "name": "signatures", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "valid_till": { + "name": "valid_till", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "signatures_api_key_id_idx": { + "name": "signatures_api_key_id_idx", + "columns": [ + { + "expression": "api_key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "signatures_app_id_apps_id_fk": { + "name": "signatures_app_id_apps_id_fk", + "tableFrom": "signatures", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "signatures_api_key_id_api_keys_id_fk": { + "name": "signatures_api_key_id_api_keys_id_fk", + "tableFrom": "signatures", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "signatures_signature_unique": { + "name": "signatures_signature_unique", + "nullsNotDistinct": false, + "columns": [ + "signature" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tus_uploads": { + "name": "tus_uploads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "upload_id": { + "name": "upload_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_id": { + "name": "app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upload_length": { + "name": "upload_length", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "upload_offset": { + "name": "upload_offset", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "temp_file_path": { + "name": "temp_file_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_complete": { + "name": "is_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "tus_uploads_app_id_apps_id_fk": { + "name": "tus_uploads_app_id_apps_id_fk", + "tableFrom": "tus_uploads", + "tableTo": "apps", + "columnsFrom": [ + "app_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tus_uploads_upload_id_unique": { + "name": "tus_uploads_upload_id_unique", + "nullsNotDistinct": false, + "columns": [ + "upload_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revision_items": { + "name": "billing_catalog_revision_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "catalog_revision_id": { + "name": "catalog_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "billing_catalog_revision_items_revision_key_uidx": { + "name": "billing_catalog_revision_items_revision_key_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_catalog_revision_items_revision_price_uidx": { + "name": "billing_catalog_revision_items_revision_price_uidx", + "columns": [ + { + "expression": "catalog_revision_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_price_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk": { + "name": "billing_catalog_revision_items_catalog_revision_id_billing_catalog_revisions_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_catalog_revisions", + "columnsFrom": [ + "catalog_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_catalog_revision_items_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_catalog_revision_items", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_catalog_revisions": { + "name": "billing_catalog_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "checkout_provider": { + "name": "checkout_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending_verification'" + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "retired_at": { + "name": "retired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_catalog_revisions_active_provider_uidx": { + "name": "billing_catalog_revisions_active_provider_uidx", + "columns": [ + { + "expression": "checkout_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_catalog_revisions\".\"status\" = 'active'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_catalog_revisions_revision_unique": { + "name": "billing_catalog_revisions_revision_unique", + "nullsNotDistinct": false, + "columns": [ + "revision" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_catalog_revisions_status_check": { + "name": "billing_catalog_revisions_status_check", + "value": "\"billing_catalog_revisions\".\"status\" IN ('pending_verification', 'active', 'retired', 'invalid', 'abandoned')" + }, + "billing_catalog_revisions_revision_check": { + "name": "billing_catalog_revisions_revision_check", + "value": "\"billing_catalog_revisions\".\"revision\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.billing_checkout_attempts": { + "name": "billing_checkout_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_plan": { + "name": "requested_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_interval": { + "name": "requested_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quoted_amount_minor": { + "name": "quoted_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quoted_currency": { + "name": "quoted_currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_checkout_session_id": { + "name": "provider_checkout_session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url_encrypted": { + "name": "checkout_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_checkout_attempts_provider_session_uidx": { + "name": "billing_checkout_attempts_provider_session_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_checkout_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"provider_checkout_session_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_idempotency_uidx": { + "name": "billing_checkout_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_checkout_attempts_entity_nonterminal_uidx": { + "name": "billing_checkout_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_checkout_attempts_billable_entity_id_user_id_fk": { + "name": "billing_checkout_attempts_billable_entity_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_payer_id_user_id_fk": { + "name": "billing_checkout_attempts_payer_id_user_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_checkout_attempts_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_checkout_attempts_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_checkout_attempts", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_checkout_attempts_attempt_id_unique": { + "name": "billing_checkout_attempts_attempt_id_unique", + "nullsNotDistinct": false, + "columns": [ + "attempt_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_checkout_attempts_status_check": { + "name": "billing_checkout_attempts_status_check", + "value": "\"billing_checkout_attempts\".\"status\" IN ('creating', 'open', 'completed', 'expired', 'abandoned', 'conflicted')" + }, + "billing_checkout_attempts_amount_check": { + "name": "billing_checkout_attempts_amount_check", + "value": "\"billing_checkout_attempts\".\"quoted_amount_minor\" > 0" + }, + "billing_checkout_attempts_plan_check": { + "name": "billing_checkout_attempts_plan_check", + "value": "\"billing_checkout_attempts\".\"requested_plan\" IN ('pro')" + }, + "billing_checkout_attempts_interval_check": { + "name": "billing_checkout_attempts_interval_check", + "value": "\"billing_checkout_attempts\".\"requested_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_change_attempts": { + "name": "billing_plan_change_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "change_id": { + "name": "change_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_catalog_revision": { + "name": "current_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "current_billing_price_entry_id": { + "name": "current_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "current_plan": { + "name": "current_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_interval": { + "name": "current_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_catalog_revision": { + "name": "target_catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target_billing_price_entry_id": { + "name": "target_billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_plan": { + "name": "target_plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_interval": { + "name": "target_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_offer_key": { + "name": "target_offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "effective_at": { + "name": "effective_at", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proration_mode": { + "name": "proration_mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_id": { + "name": "provider_payment_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_url_encrypted": { + "name": "payment_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_plan_change_attempts_idempotency_uidx": { + "name": "billing_plan_change_attempts_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_plan_change_attempts_entity_nonterminal_uidx": { + "name": "billing_plan_change_attempts_entity_nonterminal_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_plan_change_attempts_billable_entity_id_user_id_fk": { + "name": "billing_plan_change_attempts_billable_entity_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_change_attempts_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_payer_id_user_id_fk": { + "name": "billing_plan_change_attempts_payer_id_user_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_current_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "current_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_plan_change_attempts_target_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_plan_change_attempts", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "target_billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_change_attempts_change_id_unique": { + "name": "billing_plan_change_attempts_change_id_unique", + "nullsNotDistinct": false, + "columns": [ + "change_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "billing_plan_change_attempts_status_check": { + "name": "billing_plan_change_attempts_status_check", + "value": "\"billing_plan_change_attempts\".\"status\" IN ('creating', 'pending', 'succeeded', 'failed', 'conflicted')" + }, + "billing_plan_change_attempts_effective_at_check": { + "name": "billing_plan_change_attempts_effective_at_check", + "value": "\"billing_plan_change_attempts\".\"effective_at\" IN ('immediately', 'next_billing_date')" + }, + "billing_plan_change_attempts_proration_mode_check": { + "name": "billing_plan_change_attempts_proration_mode_check", + "value": "\"billing_plan_change_attempts\".\"proration_mode\" IN ('prorated_immediately', 'do_not_bill')" + }, + "billing_plan_change_attempts_current_plan_check": { + "name": "billing_plan_change_attempts_current_plan_check", + "value": "\"billing_plan_change_attempts\".\"current_plan\" IN ('pro')" + }, + "billing_plan_change_attempts_target_plan_check": { + "name": "billing_plan_change_attempts_target_plan_check", + "value": "\"billing_plan_change_attempts\".\"target_plan\" IN ('pro')" + } + }, + "isRLSEnabled": false + }, + "public.billing_plan_states": { + "name": "billing_plan_states", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_subscription_id": { + "name": "active_subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "projection_version": { + "name": "projection_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "billing_plan_states_billable_entity_id_user_id_fk": { + "name": "billing_plan_states_billable_entity_id_user_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_plan_states_active_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_plan_states", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "active_subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "billing_plan_states_billable_entity_id_unique": { + "name": "billing_plan_states_billable_entity_id_unique", + "nullsNotDistinct": false, + "columns": [ + "billable_entity_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.billing_price_entries": { + "name": "billing_price_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_minor": { + "name": "amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "provider_trial_days": { + "name": "provider_trial_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_price_entries_provider_product_uidx": { + "name": "billing_price_entries_provider_product_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_price_entries_offer_key_idx": { + "name": "billing_price_entries_offer_key_idx", + "columns": [ + { + "expression": "offer_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_price_entries_amount_check": { + "name": "billing_price_entries_amount_check", + "value": "\"billing_price_entries\".\"amount_minor\" > 0" + }, + "billing_price_entries_trial_days_check": { + "name": "billing_price_entries_trial_days_check", + "value": "\"billing_price_entries\".\"provider_trial_days\" >= 0" + }, + "billing_price_entries_currency_check": { + "name": "billing_price_entries_currency_check", + "value": "\"billing_price_entries\".\"currency\" ~ '^[A-Z]{3}$'" + }, + "billing_price_entries_plan_check": { + "name": "billing_price_entries_plan_check", + "value": "\"billing_price_entries\".\"plan\" IN ('pro')" + }, + "billing_price_entries_interval_check": { + "name": "billing_price_entries_interval_check", + "value": "\"billing_price_entries\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_provider_customers": { + "name": "billing_provider_customers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payer_email": { + "name": "payer_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'creating'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_provider_customers_provider_payer_uidx": { + "name": "billing_provider_customers_provider_payer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "payer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_provider_customer_uidx": { + "name": "billing_provider_customers_provider_customer_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_provider_customers\".\"provider_customer_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_provider_customers_idempotency_uidx": { + "name": "billing_provider_customers_idempotency_uidx", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_provider_customers_payer_id_user_id_fk": { + "name": "billing_provider_customers_payer_id_user_id_fk", + "tableFrom": "billing_provider_customers", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_provider_customers_status_check": { + "name": "billing_provider_customers_status_check", + "value": "\"billing_provider_customers\".\"status\" IN ('creating', 'active', 'conflicted')" + } + }, + "isRLSEnabled": false + }, + "public.billing_reconciliation_jobs": { + "name": "billing_reconciliation_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "plan_change_attempt_id": { + "name": "plan_change_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_customer_id": { + "name": "provider_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "operation": { + "name": "operation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'reconcile'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_reconciliation_jobs_live_checkout_uidx": { + "name": "billing_reconciliation_jobs_live_checkout_uidx", + "columns": [ + { + "expression": "checkout_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_plan_change_uidx": { + "name": "billing_reconciliation_jobs_live_plan_change_uidx", + "columns": [ + { + "expression": "plan_change_attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_subscription_uidx": { + "name": "billing_reconciliation_jobs_live_subscription_uidx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_reconciliation_jobs_live_customer_uidx": { + "name": "billing_reconciliation_jobs_live_customer_uidx", + "columns": [ + { + "expression": "provider_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL AND \"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_reconciliation_jobs_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk": { + "name": "billing_reconciliation_jobs_plan_change_attempt_id_billing_plan_change_attempts_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_plan_change_attempts", + "columnsFrom": [ + "plan_change_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk": { + "name": "billing_reconciliation_jobs_subscription_id_billing_subscriptions_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk": { + "name": "billing_reconciliation_jobs_provider_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_reconciliation_jobs", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "provider_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_reconciliation_jobs_exactly_one_subject": { + "name": "billing_reconciliation_jobs_exactly_one_subject", + "value": "((CASE WHEN \"billing_reconciliation_jobs\".\"checkout_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"plan_change_attempt_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL THEN 1 ELSE 0 END) + (CASE WHEN \"billing_reconciliation_jobs\".\"provider_customer_id\" IS NOT NULL THEN 1 ELSE 0 END)) = 1" + }, + "billing_reconciliation_jobs_status_check": { + "name": "billing_reconciliation_jobs_status_check", + "value": "\"billing_reconciliation_jobs\".\"status\" IN ('pending', 'processing', 'failed', 'completed', 'quarantined')" + }, + "billing_reconciliation_jobs_operation_check": { + "name": "billing_reconciliation_jobs_operation_check", + "value": "\"billing_reconciliation_jobs\".\"operation\" = 'reconcile' OR (\"billing_reconciliation_jobs\".\"operation\" = 'cancellation' AND \"billing_reconciliation_jobs\".\"subscription_id\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.billing_subscriptions": { + "name": "billing_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "billable_entity_id": { + "name": "billable_entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_customer_id": { + "name": "billing_customer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payer_id": { + "name": "payer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "origin_checkout_attempt_id": { + "name": "origin_checkout_attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_subscription_id": { + "name": "provider_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_product_id": { + "name": "provider_product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_price_entry_id": { + "name": "billing_price_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "catalog_revision": { + "name": "catalog_revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "offer_key": { + "name": "offer_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_period_starts_at": { + "name": "current_period_starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_ends_at": { + "name": "current_period_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "paid_through_at": { + "name": "paid_through_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trial_ends_at": { + "name": "trial_ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_entitlement_source": { + "name": "is_entitlement_source", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "provider_occurred_at": { + "name": "provider_occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_version": { + "name": "provider_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_reconciled_at": { + "name": "last_reconciled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "billing_subscriptions_provider_subscription_uidx": { + "name": "billing_subscriptions_provider_subscription_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_subscriptions_entity_source_uidx": { + "name": "billing_subscriptions_entity_source_uidx", + "columns": [ + { + "expression": "billable_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"billing_subscriptions\".\"is_entitlement_source\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "billing_subscriptions_billable_entity_id_user_id_fk": { + "name": "billing_subscriptions_billable_entity_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": [ + "billable_entity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk": { + "name": "billing_subscriptions_billing_customer_id_billing_provider_customers_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_provider_customers", + "columnsFrom": [ + "billing_customer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_payer_id_user_id_fk": { + "name": "billing_subscriptions_payer_id_user_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "user", + "columnsFrom": [ + "payer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk": { + "name": "billing_subscriptions_origin_checkout_attempt_id_billing_checkout_attempts_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_checkout_attempts", + "columnsFrom": [ + "origin_checkout_attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk": { + "name": "billing_subscriptions_billing_price_entry_id_billing_price_entries_id_fk", + "tableFrom": "billing_subscriptions", + "tableTo": "billing_price_entries", + "columnsFrom": [ + "billing_price_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_subscriptions_status_check": { + "name": "billing_subscriptions_status_check", + "value": "\"billing_subscriptions\".\"status\" IN ('pending', 'trialing', 'active', 'past_due', 'cancelled', 'expired')" + }, + "billing_subscriptions_plan_check": { + "name": "billing_subscriptions_plan_check", + "value": "\"billing_subscriptions\".\"plan\" IN ('pro')" + }, + "billing_subscriptions_interval_check": { + "name": "billing_subscriptions_interval_check", + "value": "\"billing_subscriptions\".\"billing_interval\" IN ('month', 'year')" + } + }, + "isRLSEnabled": false + }, + "public.billing_webhook_events": { + "name": "billing_webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_event_id": { + "name": "provider_event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_attempt_id": { + "name": "checkout_attempt_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_encrypted": { + "name": "payload_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload_key_version": { + "name": "payload_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verified_key_version": { + "name": "verified_key_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_attempts": { + "name": "processing_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "worker_id": { + "name": "worker_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "billing_webhook_events_provider_event_uidx": { + "name": "billing_webhook_events_provider_event_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "billing_webhook_events_queue_idx": { + "name": "billing_webhook_events_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "billing_webhook_events_status_check": { + "name": "billing_webhook_events_status_check", + "value": "\"billing_webhook_events\".\"status\" IN ('pending', 'processing', 'processed', 'ignored', 'quarantined', 'failed')" + } + }, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 7d6624dd..0929039c 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1792230978766, "tag": "0008_api_keys", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1793607644954, + "tag": "0009_media_assets_timeline", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repository.test.ts b/apps/api/src/db/repository.test.ts index f6f9df75..855f231a 100644 --- a/apps/api/src/db/repository.test.ts +++ b/apps/api/src/db/repository.test.ts @@ -16,6 +16,13 @@ import { createMediaRecord, createSignature, deleteExpiredSignatures, + findMediaByAssetIds, + getMediaSettingsRecord, + isUniqueViolation, + listTimeline, + MEDIA_ASSET_CONSTRAINT, + timelineKey, + upsertMediaSettings, findApiKeyByHash, findApiKeyForAuth, getAppByPublicId, @@ -349,3 +356,200 @@ describe("bootstrap keys", () => { ); }); }); + +describe("camera roll media", () => { + let counter = 0; + async function addMedia( + owner: { id: string }, + appId: string, + extra: Partial[0]> = {}, + ) { + counter += 1; + return createMediaRecord({ + fileName: "main.jpg", + mediaId: `cam-${counter}`, + userId: owner.id, + appId, + originalFileName: `IMG_${counter}.jpg`, + mimeType: "image/jpeg", + size: 10, + thumbnailGenerated: true, + accessControl: "private", + temp: false, + ...extra, + }); + } + + test("an asset ID is unique within an app, not across apps", async () => { + const user = await account(); + const [first] = await listApps(user.id); + const second = await createApp({ userId: user.id, name: "Other" }); + + await addMedia(user, first.id, { assetId: "asset-1" }); + await assert.rejects( + addMedia(user, first.id, { assetId: "asset-1" }), + (error) => isUniqueViolation(error, MEDIA_ASSET_CONSTRAINT), + ); + await addMedia(user, second.id, { assetId: "asset-1" }); + // Media without an asset ID never collide. + await addMedia(user, first.id); + await addMedia(user, first.id); + }); + + test("finds media by asset ID, unsealed ones included", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const other = await account(); + const [otherApp] = await listApps(other.id); + + const sealed = await addMedia(user, app.id, { assetId: "a" }); + const unsealed = await addMedia(user, app.id, { + assetId: "b", + temp: true, + }); + await addMedia(other, otherApp.id, { assetId: "c" }); + + const found = await findMediaByAssetIds({ + userId: user.id, + appId: app.id, + assetIds: ["a", "b", "c", "missing"], + }); + assert.deepEqual( + found.map((row) => [row.assetId, row.mediaId, row.temp]).sort(), + [ + ["a", sealed.mediaId, false], + ["b", unsealed.mediaId, true], + ], + ); + assert.deepEqual( + await findMediaByAssetIds({ + userId: user.id, + appId: app.id, + assetIds: [], + }), + [], + ); + }); + + test("the timeline orders by capture time and pages without gaps", async () => { + const user = await account(); + const [app] = await listApps(user.id); + + const uploadedLastShotFirst = await addMedia(user, app.id, { + takenAt: new Date("2020-01-01T00:00:00Z"), + createdAt: new Date("2026-03-01T00:00:00Z"), + }); + const noCaptureTime = await addMedia(user, app.id, { + createdAt: new Date("2025-06-01T00:00:00Z"), + }); + const newest = await addMedia(user, app.id, { + takenAt: new Date("2026-01-01T00:00:00Z"), + createdAt: new Date("2026-03-02T00:00:00Z"), + }); + // Same sort key: the ID breaks the tie. + const tiedA = await addMedia(user, app.id, { + takenAt: new Date("2024-01-01T00:00:00Z"), + }); + const tiedB = await addMedia(user, app.id, { + takenAt: new Date("2024-01-01T00:00:00Z"), + }); + await addMedia(user, app.id, { temp: true }); + + const everything = await listTimeline({ + userId: user.id, + appId: app.id, + limit: 50, + }); + const [tieFirst, tieSecond] = [tiedA, tiedB].sort((a, b) => + a.id < b.id ? 1 : -1, + ); + assert.deepEqual( + everything.map((row) => row.mediaId), + [ + newest.mediaId, + noCaptureTime.mediaId, + tieFirst.mediaId, + tieSecond.mediaId, + uploadedLastShotFirst.mediaId, + ], + ); + + const seen: string[] = []; + let before: { at: Date; id: string } | undefined; + for (let page = 0; page < 10; page += 1) { + const rows = await listTimeline({ + userId: user.id, + appId: app.id, + limit: 2, + before, + }); + if (rows.length === 0) break; + seen.push(...rows.map((row) => row.mediaId)); + const last = rows[rows.length - 1]; + before = { at: timelineKey(last), id: last.id }; + } + assert.deepEqual( + seen, + everything.map((row) => row.mediaId), + ); + }); + + test("the timeline filters by kind and never crosses apps", async () => { + const user = await account(); + const [app] = await listApps(user.id); + const other = await createApp({ userId: user.id, name: "Elsewhere" }); + + const video = await addMedia(user, app.id, { + mimeType: "video/mp4", + durationMs: 4200, + width: 1920, + height: 1080, + }); + await addMedia(user, app.id); + await addMedia(user, other.id, { mimeType: "video/mp4" }); + + const videos = await listTimeline({ + userId: user.id, + appId: app.id, + limit: 10, + kind: "video", + }); + assert.deepEqual( + videos.map((row) => [row.mediaId, row.durationMs, row.width]), + [[video.mediaId, 4200, 1920]], + ); + }); + + test("new media settings default to unset", async () => { + const user = await account(); + const [app] = await listApps(user.id); + await upsertMediaSettings({ + userId: user.id, + appId: app.id, + useWebP: false, + }); + const plain = await getMediaSettingsRecord(user.id, app.id); + assert.equal(plain?.privateThumbnails, undefined); + assert.equal(plain?.thumbnailFit, undefined); + + await upsertMediaSettings({ + userId: user.id, + appId: app.id, + privateThumbnails: true, + thumbnailFit: "contain", + }); + const set = await getMediaSettingsRecord(user.id, app.id); + assert.equal(set?.privateThumbnails, true); + assert.equal(set?.thumbnailFit, "contain"); + + // A later update that leaves them out keeps them. + await upsertMediaSettings({ + userId: user.id, + appId: app.id, + useWebP: true, + }); + const kept = await getMediaSettingsRecord(user.id, app.id); + assert.equal(kept?.privateThumbnails, true); + assert.equal(kept?.thumbnailFit, "contain"); + }); +}); diff --git a/apps/api/src/db/repository.ts b/apps/api/src/db/repository.ts index 46624b77..2ac3f82d 100644 --- a/apps/api/src/db/repository.ts +++ b/apps/api/src/db/repository.ts @@ -106,6 +106,15 @@ export type MediaRecord = { group?: string; caption?: string; temp: boolean; + /** Client-chosen ID of the source asset; unique per app. */ + assetId?: string; + /** When the content was captured, as reported by the client. */ + takenAt?: Date; + width?: number; + height?: number; + durationMs?: number; + /** The thumbnail is in the private bucket, behind a signed URL. */ + thumbnailPrivate: boolean; createdAt: Date; updatedAt: Date; }; @@ -133,6 +142,10 @@ export type MediaSettingsRecord = { webpOutputQuality?: number; thumbnailWidth?: number; thumbnailHeight?: number; + /** Keep thumbnails of private files in the private bucket. */ + privateThumbnails?: boolean; + /** `crop` is the 16:9 center crop; `contain` keeps the aspect ratio. */ + thumbnailFit?: "crop" | "contain"; }; export type SignatureRecord = { @@ -156,6 +169,9 @@ export type TusMetadata = { accessControl: string; caption?: string; group?: string; + assetId?: string; + /** ISO 8601 capture time. */ + takenAt?: string; }; export type TusUploadRecord = { @@ -238,6 +254,12 @@ function mapMedia(row: typeof media.$inferSelect): MediaRecord { group: row.group || undefined, caption: row.caption || undefined, temp: row.temp, + assetId: row.assetId ?? undefined, + takenAt: row.takenAt ?? undefined, + width: row.width ?? undefined, + height: row.height ?? undefined, + durationMs: row.durationMs ?? undefined, + thumbnailPrivate: row.thumbnailPrivate, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -812,9 +834,13 @@ function mediaKindCondition(kind: MediaKindFilter): SQL { } export async function createMediaRecord( - input: Omit & { + input: Omit< + MediaRecord, + "id" | "_id" | "createdAt" | "updatedAt" | "thumbnailPrivate" + > & { id?: string; createdAt?: Date; + thumbnailPrivate?: boolean; }, ): Promise { const db = getDb(); @@ -835,6 +861,12 @@ export async function createMediaRecord( group: input.group, caption: input.caption, temp: input.temp, + assetId: input.assetId, + takenAt: input.takenAt, + width: input.width, + height: input.height, + durationMs: input.durationMs, + thumbnailPrivate: input.thumbnailPrivate ?? false, createdAt: timestamp, updatedAt: input.createdAt || timestamp, }) @@ -843,6 +875,94 @@ export async function createMediaRecord( return mapMedia(row); } +/** Postgres reports a unique violation as code 23505. Drizzle wraps the + * driver error, so the code can sit on `cause`. */ +export function isUniqueViolation(error: unknown, constraint: string): boolean { + for ( + let current: any = error, depth = 0; + current && depth < 4; + current = current.cause, depth += 1 + ) { + if ( + current.code === "23505" && + (current.constraint === constraint || + String(current.message ?? "").includes(constraint)) + ) { + return true; + } + } + return false; +} + +export const MEDIA_ASSET_CONSTRAINT = "media_app_asset_uidx"; + +/** The media in an app that carry any of these asset IDs, unsealed uploads + * included, so a client can finish an upload it did not seal. */ +export async function findMediaByAssetIds(input: { + userId: string; + appId: string; + assetIds: string[]; +}): Promise { + if (input.assetIds.length === 0) return []; + const db = getDb(); + const rows = await db + .select() + .from(media) + .where( + and( + eq(media.userId, input.userId), + eq(media.appId, input.appId), + inArray(media.assetId, input.assetIds), + ), + ); + return rows.map(mapMedia); +} + +/** Where a media item sits in the timeline: when it was captured, or else + * uploaded. */ +export function timelineKey( + record: Pick, +): Date { + return record.takenAt ?? record.createdAt; +} + +/** Sealed media, newest capture first. `before` is the key and ID of the last + * item already seen; paging by it keeps pages stable while uploads land. */ +export async function listTimeline(input: { + userId: string; + appId: string; + limit: number; + before?: { at: Date; id: string }; + kind?: MediaKindFilter; + group?: string; + access?: "public" | "private"; +}): Promise { + const db = getDb(); + const sortKey = sql`coalesce(${media.takenAt}, ${media.createdAt})`; + const filters = [ + eq(media.userId, input.userId), + eq(media.appId, input.appId), + eq(media.temp, false), + ]; + if (input.access) filters.push(eq(media.accessControl, input.access)); + if (input.group?.trim()) { + filters.push(like(media.group, `${escapeLike(input.group.trim())}%`)); + } + if (input.kind) filters.push(mediaKindCondition(input.kind)); + if (input.before) { + filters.push( + sql`(${sortKey}, ${media.id}) < (${input.before.at.toISOString()}::timestamptz, ${input.before.id})`, + ); + } + const rows = await db + .select() + .from(media) + .where(and(...filters)) + .orderBy(sql`${sortKey} desc`, desc(media.id)) + .limit(input.limit); + return rows.map(mapMedia); +} + export async function getMediaRecord(input: { userId: string; appId: string; @@ -1096,6 +1216,11 @@ export async function getMediaSettingsRecord( webpOutputQuality: row.webpOutputQuality ?? undefined, thumbnailWidth: row.thumbnailWidth ?? undefined, thumbnailHeight: row.thumbnailHeight ?? undefined, + privateThumbnails: row.privateThumbnails ?? undefined, + thumbnailFit: + row.thumbnailFit === "contain" || row.thumbnailFit === "crop" + ? row.thumbnailFit + : undefined, }; } @@ -1113,6 +1238,8 @@ export async function upsertMediaSettings( webpOutputQuality: input.webpOutputQuality, thumbnailWidth: input.thumbnailWidth, thumbnailHeight: input.thumbnailHeight, + privateThumbnails: input.privateThumbnails, + thumbnailFit: input.thumbnailFit, }) .onConflictDoUpdate({ target: mediaSettings.appId, @@ -1121,6 +1248,8 @@ export async function upsertMediaSettings( webpOutputQuality: input.webpOutputQuality, thumbnailWidth: input.thumbnailWidth, thumbnailHeight: input.thumbnailHeight, + privateThumbnails: input.privateThumbnails, + thumbnailFit: input.thumbnailFit, }, }); } diff --git a/apps/api/src/db/schema/domain.ts b/apps/api/src/db/schema/domain.ts index 2556b95c..dfc2e188 100644 --- a/apps/api/src/db/schema/domain.ts +++ b/apps/api/src/db/schema/domain.ts @@ -115,6 +115,16 @@ export const media = pgTable( group: text("group_name"), caption: text("caption"), temp: boolean("temp").notNull().default(true), + /** Opaque, client-chosen ID of the source asset (for example a photo + * on a phone). Unique per app, so a retried upload cannot duplicate. */ + assetId: text("asset_id"), + /** When the content was captured, as reported by the client. */ + takenAt: timestamp("taken_at", { withTimezone: true }), + width: bigint("width", { mode: "number" }), + height: bigint("height", { mode: "number" }), + durationMs: bigint("duration_ms", { mode: "number" }), + /** The thumbnail is in the private bucket, behind a signed URL. */ + thumbnailPrivate: boolean("thumbnail_private").notNull().default(false), createdAt: timestamp("created_at", { withTimezone: true }).notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(), }, @@ -124,6 +134,15 @@ export const media = pgTable( table.appId, table.temp, ), + appAsset: uniqueIndex("media_app_asset_uidx") + .on(table.appId, table.assetId) + .where(sql`${table.assetId} is not null`), + timeline: index("media_timeline_idx").on( + table.appId, + table.temp, + sql`coalesce(${table.takenAt}, ${table.createdAt}) desc`, + sql`${table.id} desc`, + ), }), ); @@ -140,6 +159,10 @@ export const mediaSettings = pgTable("media_settings", { webpOutputQuality: bigint("webp_output_quality", { mode: "number" }), thumbnailWidth: bigint("thumbnail_width", { mode: "number" }), thumbnailHeight: bigint("thumbnail_height", { mode: "number" }), + /** Keep thumbnails of private files in the private bucket. */ + privateThumbnails: boolean("private_thumbnails"), + /** `crop` is the 16:9 center crop; `contain` keeps the aspect ratio. */ + thumbnailFit: text("thumbnail_fit"), }); export const signatures = pgTable( diff --git a/apps/api/src/media/__tests__/camera-roll.test.ts b/apps/api/src/media/__tests__/camera-roll.test.ts new file mode 100644 index 00000000..f725fcb2 --- /dev/null +++ b/apps/api/src/media/__tests__/camera-roll.test.ts @@ -0,0 +1,483 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync } from "node:fs"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { after, before, describe, test } from "node:test"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import express from "express"; +import { FakeS3 } from "./fake-s3"; + +// These are read when the modules below load, so set them first. +const workDir = mkdtempSync(path.join(tmpdir(), "medialit-camera-roll-")); +const uploadDir = path.join(workDir, "uploads"); +const s3 = new FakeS3(); +process.env.TEMP_FILE_DIR_FOR_UPLOADS = uploadDir; +process.env.MEDIALIT_DEPLOYMENT_MODE = "oss"; +process.env.CLOUD_KEY = "test"; +process.env.CLOUD_SECRET = "test"; +process.env.CLOUD_REGION = "us-east-1"; +process.env.CLOUD_BUCKET_NAME = "private-bucket"; +process.env.CLOUD_PUBLIC_BUCKET_NAME = "public-bucket"; + +let server: Server; +let baseUrl: string; +let secret: string; +let appId: string; +let png: Buffer; + +before(async () => { + const { mkdirSync } = await import("node:fs"); + mkdirSync(path.join(uploadDir, "tus-uploads"), { recursive: true }); + await s3.start(); + process.env.CLOUD_ENDPOINT = s3.url; + process.env.CLOUD_ENDPOINT_PUBLIC = s3.url; + process.env.CDN_ENDPOINT = `${s3.url}/public-bucket`; + + const schema = await import("../../db/schema/index.js"); + const { setDb, createAccount, listApps } = await import("@/db"); + const { createApiKey } = await import("../../apikey/queries"); + const { default: mediaRoutes } = await import("../routes"); + const { default: tusRoutes } = await import("../../tus/routes"); + + const db = drizzle(new PGlite(), { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../../drizzle", + ), + }); + setDb(db as any); + + const owner = await createAccount({ email: "camera@example.com" }); + const [app] = await listApps(owner.id); + appId = app.id; + ({ secret } = await createApiKey({ appId: app.id, name: "Phone" })); + + const pngPath = path.join(workDir, "photo.png"); + execFileSync("ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=green:s=96x54", + "-frames:v", + "1", + pngPath, + ]); + png = readFileSync(pngPath); + + const app_ = express(); + app_.use(express.json()); + app_.use("/media", tusRoutes); + app_.use("/media", mediaRoutes); + server = app_.listen(0); + await new Promise((resolve) => server.once("listening", resolve)); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +after(async () => { + await new Promise((resolve) => server.close(() => resolve())); + await s3.stop(); +}); + +const auth = () => ({ "x-medialit-apikey": secret }); + +function upload(fields: Record, name = "IMG_0001.png") { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) form.append(key, value); + form.append( + "file", + new Blob([new Uint8Array(png)], { type: "image/png" }), + name, + ); + return fetch(`${baseUrl}/media/create`, { + method: "POST", + headers: auth(), + body: form, + }); +} + +function post(pathname: string, body?: unknown) { + return fetch(`${baseUrl}${pathname}`, { + method: "POST", + headers: { ...auth(), "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); +} + +function timeline(query = "") { + return fetch(`${baseUrl}/media/timeline${query}`, { headers: auth() }); +} + +const privateKeys = () => s3.keys("private-bucket"); +const tempFiles = () => + readdirSync(uploadDir).filter((name) => name !== "tus-uploads"); + +describe("camera roll uploads over REST", () => { + test("stores the asset ID, capture time and dimensions", async () => { + const response = await upload({ + assetId: "ios:rest-1", + takenAt: "2024-05-01T10:00:00.000Z", + }); + assert.equal(response.status, 200); + const media = await response.json(); + assert.equal(media.assetId, "ios:rest-1"); + assert.equal(media.takenAt, "2024-05-01T10:00:00.000Z"); + assert.equal(media.width, 96); + assert.equal(media.height, 54); + assert.equal(media.durationMs, undefined); + assert.equal(privateKeys().length, 2, "main file and thumbnail"); + }); + + test("refuses the same asset again, leaving nothing behind", async () => { + const keysBefore = privateKeys(); + const filesBefore = tempFiles(); + const response = await upload({ assetId: "ios:rest-1" }); + assert.equal(response.status, 409); + const body = await response.json(); + assert.equal(body.error, "asset_exists"); + assert.equal(body.sealed, false); + assert.ok(body.mediaId); + assert.deepEqual(privateKeys(), keysBefore); + assert.deepEqual(tempFiles(), filesBefore); + }); + + test("rejects a malformed asset ID or capture time", async () => { + const filesBefore = tempFiles(); + const invalid: Record[] = [ + { assetId: "has spaces" }, + { assetId: "a".repeat(201) }, + { takenAt: "yesterday" }, + { takenAt: "2999-01-01T00:00:00Z" }, + ]; + for (const fields of invalid) { + const response = await upload(fields); + assert.equal(response.status, 400, JSON.stringify(fields)); + } + assert.deepEqual(tempFiles(), filesBefore); + }); + + test("two uploads of one asset at once leave exactly one", async () => { + const [a, b] = await Promise.all([ + upload({ assetId: "ios:race" }), + upload({ assetId: "ios:race" }), + ]); + assert.deepEqual([a.status, b.status].sort(), [200, 409]); + const found = await ( + await post("/media/get/assets", { assetIds: ["ios:race"] }) + ).json(); + assert.equal(found.length, 1); + // Every stored object belongs to a record that exists. + const records = new Set( + ( + await ( + await post("/media/get/assets", { + assetIds: ["ios:race", "ios:rest-1"], + }) + ).json() + ).map((row: any) => row.mediaId), + ); + for (const key of privateKeys()) { + assert.ok( + [...records].some((mediaId) => key.includes(String(mediaId))), + `orphaned object ${key}`, + ); + } + }); +}); + +describe("asset lookup", () => { + test("reports which assets exist and whether they are sealed", async () => { + const created = await (await upload({ assetId: "ios:lookup" })).json(); + const before = await ( + await post("/media/get/assets", { + assetIds: ["ios:lookup", "ios:nope"], + }) + ).json(); + assert.deepEqual(before, [ + { assetId: "ios:lookup", mediaId: created.mediaId, sealed: false }, + ]); + + const seal = await post(`/media/seal/${created.mediaId}`); + assert.equal(seal.status, 200); + const after = await ( + await post("/media/get/assets", { assetIds: ["ios:lookup"] }) + ).json(); + assert.equal(after[0].sealed, true); + }); + + test("validates the request", async () => { + for (const body of [ + {}, + { assetIds: [] }, + { assetIds: "x" }, + { assetIds: ["bad id"] }, + { assetIds: Array.from({ length: 201 }, (_, i) => `a${i}`) }, + ]) { + assert.equal( + (await post("/media/get/assets", body)).status, + 400, + JSON.stringify(body), + ); + } + }); + + test("needs credentials", async () => { + const response = await fetch(`${baseUrl}/media/get/assets`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ assetIds: ["x"] }), + }); + assert.equal(response.status, 401); + }); +}); + +describe("timeline", () => { + const seeded: Record = {}; + + before(async () => { + // Uploaded in this order, captured in another. + for (const [assetId, takenAt] of [ + ["tl-old", "2019-01-01T00:00:00.000Z"], + ["tl-new", "2024-12-31T00:00:00.000Z"], + ["tl-mid", "2022-06-15T00:00:00.000Z"], + ]) { + const media = await (await upload({ assetId, takenAt })).json(); + seeded[assetId] = media.mediaId; + await post(`/media/seal/${media.mediaId}`); + } + // Uploaded but never sealed. + await upload({ + assetId: "tl-unsealed", + takenAt: "2025-01-01T00:00:00Z", + }); + }); + + test("lists sealed media, newest capture first", async () => { + const response = await timeline("?limit=200"); + assert.equal(response.status, 200); + const page = await response.json(); + const order = page.items + .map((item: any) => item.assetId) + .filter((id: string) => id?.startsWith("tl-")); + assert.deepEqual(order, ["tl-new", "tl-mid", "tl-old"]); + assert.equal(page.nextCursor, null); + const item = page.items.find((i: any) => i.assetId === "tl-new"); + assert.equal(item.width, 96); + assert.equal(item.access, "private"); + assert.equal(item.takenAt, "2024-12-31T00:00:00.000Z"); + assert.ok(item.thumbnail.startsWith("http")); + assert.ok(item.createdAt); + }); + + test("pages by cursor without repeating or skipping", async () => { + const all = (await (await timeline("?limit=200")).json()).items.map( + (item: any) => item.mediaId, + ); + assert.ok(all.length >= 4); + + const seen: string[] = []; + let cursor: string | null = null; + for (let page = 0; page < all.length + 2; page += 1) { + const query: string = `?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`; + const body: any = await (await timeline(query)).json(); + assert.ok(body.items.length <= 2); + seen.push(...body.items.map((item: any) => item.mediaId)); + cursor = body.nextCursor; + if (!cursor) break; + } + assert.deepEqual(seen, all); + }); + + test("a page boundary survives an upload landing between pages", async () => { + const first = await (await timeline("?limit=1")).json(); + assert.ok(first.nextCursor); + const late = await ( + await upload({ + assetId: "tl-late", + takenAt: "2030-01-01T00:00:00.000Z", + }) + ).json(); + await post(`/media/seal/${late.mediaId}`); + const second = await ( + await timeline( + `?limit=1&cursor=${encodeURIComponent(first.nextCursor)}`, + ) + ).json(); + assert.notEqual(second.items[0].mediaId, first.items[0].mediaId); + assert.notEqual(second.items[0].mediaId, late.mediaId); + }); + + test("filters by kind", async () => { + const images = await (await timeline("?kind=image&limit=200")).json(); + assert.ok(images.items.length > 0); + const videos = await (await timeline("?kind=video")).json(); + assert.deepEqual(videos, { items: [], nextCursor: null }); + }); + + test("rejects bad parameters", async () => { + for (const query of [ + "?limit=0", + "?limit=201", + "?limit=abc", + "?cursor=forged", + "?kind=audio", + ]) { + assert.equal((await timeline(query)).status, 400, query); + } + }); + + test("needs credentials", async () => { + assert.equal((await fetch(`${baseUrl}/media/timeline`)).status, 401); + }); +}); + +describe("camera roll uploads over TUS", () => { + const b64 = (value: string) => Buffer.from(value).toString("base64"); + + function createUpload(metadata: Record) { + const header = Object.entries(metadata) + .map(([key, value]) => `${key} ${b64(value)}`) + .join(","); + return fetch(`${baseUrl}/media/create/resumable`, { + method: "POST", + headers: { + ...auth(), + "tus-resumable": "1.0.0", + "upload-length": String(png.length), + "upload-metadata": header, + }, + }); + } + + async function finish(location: string) { + return fetch(location, { + method: "PATCH", + headers: { + ...auth(), + "tus-resumable": "1.0.0", + "upload-offset": "0", + "content-type": "application/offset+octet-stream", + }, + body: new Uint8Array(png), + }); + } + + const base = (assetId: string) => ({ + fileName: "IMG_9000.png", + mimeType: "image/png", + access: "private", + assetId, + takenAt: "2023-03-03T03:03:03.000Z", + }); + + test("carries the asset ID and capture time into the media", async () => { + const created = await createUpload(base("ios:tus-1")); + assert.equal(created.status, 201); + const done = await finish(created.headers.get("location")!); + assert.equal(done.status, 204); + const media = JSON.parse(done.headers.get("media")!); + assert.equal(media.assetId, "ios:tus-1"); + assert.equal(media.takenAt, "2023-03-03T03:03:03.000Z"); + assert.equal(media.width, 96); + }); + + test("refuses a known asset before any bytes are sent", async () => { + const response = await createUpload(base("ios:tus-1")); + assert.equal(response.status, 409); + const body = JSON.parse(await response.text()); + assert.equal(body.error, "asset_exists"); + assert.equal(body.sealed, false); + assert.ok(body.mediaId); + }); + + test("rejects bad metadata", async () => { + assert.equal((await createUpload({ ...base("bad id") })).status, 400); + assert.equal( + (await createUpload({ ...base("ios:tus-2"), takenAt: "nope" })) + .status, + 400, + ); + }); + + test("still takes uploads that carry no asset ID", async () => { + const { assetId, takenAt, ...plain } = base("x"); + void assetId; + void takenAt; + const created = await createUpload(plain); + assert.equal(created.status, 201); + const done = await finish(created.headers.get("location")!); + assert.equal(done.status, 204); + const media = JSON.parse(done.headers.get("media")!); + assert.equal(media.assetId, undefined); + assert.equal(media.takenAt, undefined); + }); +}); + +describe("the asset ID backstop", () => { + test("removes what a losing upload stored", async () => { + const { createMediaForAsset } = await import("../service"); + const { AssetExistsError } = await import("../errors"); + const { default: generateKey } = await import("../utils/generate-key"); + const { Constants } = await import("@medialit/models"); + const { listApps, findUserByEmail } = await import("@/db"); + + const owner = (await findUserByEmail("camera@example.com"))!; + const [app] = await listApps(owner.id); + const winner = await (await upload({ assetId: "ios:backstop" })).json(); + + // The loser got past the pre-check and already stored its objects. + const mainKey = generateKey({ + mediaId: "loser", + path: Constants.PathKey.PRIVATE, + filename: "main.png", + }); + const thumbKey = generateKey({ + mediaId: "loser", + path: Constants.PathKey.PRIVATE, + filename: "thumb.webp", + }); + s3.objects.set(`private-bucket/${mainKey}`, Buffer.from("x")); + s3.objects.set(`private-bucket/${thumbKey}`, Buffer.from("x")); + + await assert.rejects( + createMediaForAsset( + { + fileName: "main.png", + mediaId: "loser", + userId: owner.id, + appId: app.id, + originalFileName: "IMG.png", + mimeType: "image/png", + size: 1, + thumbnailGenerated: true, + accessControl: Constants.AccessControl.PRIVATE, + temp: true, + assetId: "ios:backstop", + }, + { + userId: owner.id, + appId: app.id, + mediaId: "loser", + fileName: "main.png", + thumbnailGenerated: true, + }, + ), + (error: unknown) => + error instanceof AssetExistsError && + error.mediaId === winner.mediaId && + error.sealed === false, + ); + assert.equal(privateKeys().includes(mainKey), false); + assert.equal(privateKeys().includes(thumbKey), false); + // The winner's own objects are untouched. + assert.ok(privateKeys().some((key) => key.includes(winner.mediaId))); + }); +}); diff --git a/apps/api/src/media/__tests__/fake-s3.ts b/apps/api/src/media/__tests__/fake-s3.ts new file mode 100644 index 00000000..6d62a265 --- /dev/null +++ b/apps/api/src/media/__tests__/fake-s3.ts @@ -0,0 +1,80 @@ +import http, { type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +/** + * Just enough of S3 for the upload paths: path-style PUT, copy, DELETE and + * tagging. Objects are kept in memory as `bucket/key`. + */ +export class FakeS3 { + readonly objects = new Map(); + private server: Server | null = null; + url = ""; + + async start(): Promise { + this.server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => this.handle(req, res, Buffer.concat(chunks))); + }); + await new Promise((resolve) => + this.server!.listen(0, "127.0.0.1", resolve), + ); + this.url = `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`; + } + + async stop(): Promise { + await new Promise((resolve) => + this.server ? this.server.close(() => resolve()) : resolve(), + ); + } + + keys(bucket: string): string[] { + const prefix = `${bucket}/`; + return [...this.objects.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => key.slice(prefix.length)) + .sort(); + } + + private handle( + req: http.IncomingMessage, + res: http.ServerResponse, + body: Buffer, + ) { + const url = new URL(req.url ?? "/", this.url); + const objectKey = decodeURIComponent(url.pathname.slice(1)); + const copySource = req.headers["x-amz-copy-source"]; + + if (req.method === "PUT" && copySource) { + const source = decodeURIComponent(String(copySource)).replace( + /^\//, + "", + ); + const found = this.objects.get(source); + if (!found) { + res.writeHead(404).end(); + return; + } + this.objects.set(objectKey, found); + res.writeHead(200, { "content-type": "application/xml" }); + res.end("x"); + return; + } + if (req.method === "PUT") { + this.objects.set(objectKey, body); + res.writeHead(200, { etag: '"x"' }).end(); + return; + } + if (req.method === "DELETE") { + this.objects.delete(objectKey); + res.writeHead(204).end(); + return; + } + if (req.method === "GET" && url.searchParams.has("tagging")) { + res.writeHead(200, { "content-type": "application/xml" }); + res.end(""); + return; + } + res.writeHead(404).end(); + } +} diff --git a/apps/api/src/media/errors.ts b/apps/api/src/media/errors.ts new file mode 100644 index 00000000..edc3c21e --- /dev/null +++ b/apps/api/src/media/errors.ts @@ -0,0 +1,19 @@ +/** An upload carried an asset ID the app already has. */ +export class AssetExistsError extends Error { + constructor( + readonly mediaId: string | undefined, + readonly sealed: boolean | undefined, + ) { + super("An upload with this asset ID already exists"); + this.name = "AssetExistsError"; + } + + /** The body a client reads to resume: seal it if it is not sealed yet. */ + toBody() { + return { + error: "asset_exists", + mediaId: this.mediaId, + sealed: this.sealed, + }; + } +} diff --git a/apps/api/src/media/handlers.ts b/apps/api/src/media/handlers.ts index bbe0c30a..618c15ee 100644 --- a/apps/api/src/media/handlers.ts +++ b/apps/api/src/media/handlers.ts @@ -11,11 +11,25 @@ import mediaService from "./service"; import { getMediaCount as getCount, getTotalSpace } from "./queries"; import { maxStorageFor } from "../billing/entitlements"; import { getSignatureFromReq } from "../signature/utils"; -import { getMediaSchema, uploadMediaSchema } from "./schemas"; +import { + assetLookupSchema, + getMediaSchema, + timelineQuerySchema, + uploadMediaSchema, +} from "./schemas"; +import { AssetExistsError } from "./errors"; +import { InvalidCursorError } from "./service"; +import { removeTempFile } from "./storage-middleware"; function validateUploadOptions(req: Request): Joi.ValidationResult { - const { caption, access, group } = req.body; - return uploadMediaSchema.validate({ caption, access, group }); + const { caption, access, group, assetId, takenAt } = req.body; + return uploadMediaSchema.validate({ + caption, + access, + group, + assetId, + takenAt, + }); } export async function uploadMedia( @@ -29,19 +43,41 @@ export async function uploadMedia( return res.status(400).json({ error: FILE_IS_REQUIRED }); } - const { error } = validateUploadOptions(req); + const { error, value: options } = validateUploadOptions(req); if (error) { + await removeTempFile(req.files.file.tempFilePath); return res.status(400).json({ error: error.message }); } const { file } = req.files; const { access, caption } = req.body; + const { assetId, takenAt } = options; // A signature's group wins over the form's. const group = req.group ?? req.body.group; const userId = req.user.id; const appId = req.appId; try { + if (assetId) { + // Say so before the file is stored, not after. + const [existing] = await mediaService.findAssets({ + userId, + appId, + assetIds: [assetId], + }); + if (existing) { + await removeTempFile(file.tempFilePath); + return res + .status(409) + .json( + new AssetExistsError( + existing.mediaId, + existing.sealed, + ).toBody(), + ); + } + } + const mediaId = await mediaService.upload({ userId, appId, @@ -50,6 +86,8 @@ export async function uploadMedia( caption, group, signature: getSignatureFromReq(req), + assetId, + takenAt, }); const media = await mediaService.getMediaDetails({ @@ -60,6 +98,54 @@ export async function uploadMedia( return res.status(200).json(media); } catch (err: any) { + if (err instanceof AssetExistsError) { + return res.status(409).json(err.toBody()); + } + logger.error({ err }, err.message); + return res.status(500).json({ error: err.message }); + } +} + +export async function getMediaAssets(req: any, res: any) { + const { error, value } = assetLookupSchema.validate(req.body ?? {}); + if (error) { + return res.status(400).json({ error: error.message }); + } + + try { + const found = await mediaService.findAssets({ + userId: req.user.id, + appId: req.appId, + assetIds: value.assetIds, + }); + return res.status(200).json(found); + } catch (err: any) { + logger.error({ err }, err.message); + return res.status(500).json({ error: err.message }); + } +} + +export async function getMediaTimeline(req: any, res: any) { + const { error, value } = timelineQuerySchema.validate(req.query); + if (error) { + return res.status(400).json({ error: error.message }); + } + + try { + const page = await mediaService.getTimeline({ + userId: req.user.id, + appId: req.appId, + limit: value.limit, + cursor: value.cursor, + kind: value.kind, + access: value.access, + group: value.group, + }); + return res.status(200).json(page); + } catch (err: any) { + if (err instanceof InvalidCursorError) { + return res.status(400).json({ error: err.message }); + } logger.error({ err }, err.message); return res.status(500).json({ error: err.message }); } diff --git a/apps/api/src/media/queries.ts b/apps/api/src/media/queries.ts index 670e2c4b..50cc0f76 100644 --- a/apps/api/src/media/queries.ts +++ b/apps/api/src/media/queries.ts @@ -143,6 +143,12 @@ export async function createMedia( group: mediaData.group, caption: mediaData.caption, temp: mediaData.temp ?? true, + assetId: mediaData.assetId, + takenAt: mediaData.takenAt, + width: mediaData.width, + height: mediaData.height, + durationMs: mediaData.durationMs, + thumbnailPrivate: mediaData.thumbnailPrivate, }); } diff --git a/apps/api/src/media/routes.ts b/apps/api/src/media/routes.ts index 8958fd9a..eb335468 100644 --- a/apps/api/src/media/routes.ts +++ b/apps/api/src/media/routes.ts @@ -2,7 +2,9 @@ import express from "express"; import apikey from "../apikey/middleware"; import { getMedia, + getMediaAssets, getMediaDetails, + getMediaTimeline, uploadMedia, deleteMedia, sealMedia, @@ -44,7 +46,9 @@ router.post( file: { type: "string", format: "binary" }, caption: { type: "string" }, access: { type: "string", enum: ["public", "private"] }, - group: { type: "string" } + group: { type: "string" }, + assetId: { type: "string", description: "Your own ID for the source of this file. Unique per app, so retrying the same upload cannot create a duplicate." }, + takenAt: { type: "string", format: "date-time", description: "When the content was captured. Orders the timeline." } }, required: ["file"] } @@ -61,6 +65,14 @@ router.post( } #swagger.responses[400] = { description: 'Bad Request' } #swagger.responses[401] = { description: 'Unauthorized' } + #swagger.responses[409] = { + description: 'The app already has a file with this `assetId`. Seal it if `sealed` is false.', + content: { + "application/json": { + schema: { $ref: '#/components/schemas/AssetExistsResponse' } + } + } + } #swagger.responses[404] = { description: 'Invalid signature', content: { @@ -126,6 +138,84 @@ router.post( requireScope(DATA_READ_SCOPE), getTotalSpaceOccupied, ); +router.post( + "/get/assets", + /* + #swagger.tags = ['Media'] + #swagger.summary = 'Look Up Assets' + #swagger.description = 'Tells which of your `assetId`s the app already has, sealed or not. Call it before uploading a batch to skip what is already there.' + #swagger.security = [{ "bearerAuth": [] }, { "apiKeyAuth": [] }] + #swagger.requestBody = { + required: true, + content: { + "application/json": { + schema: { $ref: '#/components/schemas/AssetLookupPayload' } + } + } + } + #swagger.responses[200] = { + description: 'The assets that exist', + content: { + "application/json": { + schema: { $ref: '#/components/schemas/AssetLookupResponse' } + } + } + } + #swagger.responses[400] = { description: 'Bad Request' } + #swagger.responses[401] = { description: 'Unauthorized' } + #swagger.responses[500] = { description: 'Internal Server Error' } + */ + authenticatedApiLimiter, + apikey, + requireScope(DATA_READ_SCOPE), + getMediaAssets, +); +router.get( + "/timeline", + /* + #swagger.tags = ['Media'] + #swagger.summary = 'Media Timeline' + #swagger.description = 'Sealed media, newest first by `takenAt`, or by `createdAt` where there is no `takenAt`. Follow `nextCursor` for the next page; pages stay stable while uploads land.' + #swagger.security = [{ "bearerAuth": [] }, { "apiKeyAuth": [] }] + #swagger.parameters['limit'] = { + in: 'query', + description: 'Items per page, 1 to 200. Defaults to 50.', + schema: { type: 'integer', minimum: 1, maximum: 200 } + } + #swagger.parameters['cursor'] = { + in: 'query', + description: 'The nextCursor from the previous page', + schema: { type: 'string' } + } + #swagger.parameters['kind'] = { + in: 'query', + schema: { type: 'string', enum: ['image', 'video', 'pdf', 'other'] } + } + #swagger.parameters['access'] = { + in: 'query', + schema: { type: 'string', enum: ['public', 'private'] } + } + #swagger.parameters['group'] = { + in: 'query', + schema: { type: 'string' } + } + #swagger.responses[200] = { + description: 'A page of the timeline', + content: { + "application/json": { + schema: { $ref: '#/components/schemas/TimelineResponse' } + } + } + } + #swagger.responses[400] = { description: 'Bad Request' } + #swagger.responses[401] = { description: 'Unauthorized' } + #swagger.responses[500] = { description: 'Internal Server Error' } + */ + authenticatedApiLimiter, + apikey, + requireScope(DATA_READ_SCOPE), + getMediaTimeline, +); router.post( "/get/:mediaId", /* diff --git a/apps/api/src/media/schemas.ts b/apps/api/src/media/schemas.ts index 57d81df1..1d9e4a4f 100644 --- a/apps/api/src/media/schemas.ts +++ b/apps/api/src/media/schemas.ts @@ -1,10 +1,61 @@ import Joi from "joi"; import { AccessControl } from "@medialit/models"; +/** Opaque to the server; letters, digits and `.`, `_`, `:`, `-` keep it safe + * to log and to use in a URL. */ +export const assetIdSchema = Joi.string() + .pattern(/^[A-Za-z0-9._:-]{1,200}$/) + .messages({ + "string.pattern.base": + '"assetId" must be 1 to 200 letters, digits, ".", "_", ":" or "-"', + }); + +const ONE_DAY_MS = 24 * 60 * 60 * 1000; + +/** ISO 8601. A little in the future is clock skew; far ahead is a mistake. */ +export const takenAtSchema = Joi.date() + .iso() + .min("1970-01-01T00:00:00.000Z") + .custom((value: Date, helpers) => + value.getTime() > Date.now() + ONE_DAY_MS + ? helpers.error("date.max", { limit: "tomorrow" }) + : value, + ); + export const uploadMediaSchema = Joi.object({ caption: Joi.string().optional().allow(""), access: Joi.string().valid("public", "private").optional(), group: Joi.string().optional(), + assetId: assetIdSchema.optional(), + takenAt: takenAtSchema.optional(), +}); + +export const MAX_ASSET_LOOKUP = 200; + +export const assetLookupSchema = Joi.object({ + assetIds: Joi.array() + .items(assetIdSchema) + .min(1) + .max(MAX_ASSET_LOOKUP) + .required(), +}); + +export const assetLookupResponseSchema = Joi.array().items( + Joi.object({ + assetId: Joi.string().required(), + mediaId: Joi.string().required(), + sealed: Joi.boolean().required(), + }), +); + +export const MAX_TIMELINE_PAGE = 200; + +export const timelineQuerySchema = Joi.object({ + limit: Joi.number().integer().min(1).max(MAX_TIMELINE_PAGE).default(50), + cursor: Joi.string().max(512), + kind: Joi.string().valid("image", "video", "pdf", "other"), + access: Joi.string().valid("public", "private"), + group: Joi.string(), }); export const getMediaSchema = Joi.object({ @@ -24,6 +75,11 @@ export interface MediaResponse { thumbnail: string; caption?: string; group?: string; + assetId?: string; + takenAt?: Date; + width?: number; + height?: number; + durationMs?: number; } export interface MediaListItemResponse { @@ -38,6 +94,15 @@ export interface MediaListItemResponse { createdAt: Date; } +export interface TimelineItemResponse extends MediaListItemResponse { + /** `null` when the uploader sent none; order by `createdAt` then. */ + takenAt: Date | null; + assetId?: string; + width?: number; + height?: number; + durationMs?: number; +} + export const mediaResponseSchema = Joi.object({ mediaId: Joi.string().required(), originalFileName: Joi.string().required(), @@ -48,6 +113,11 @@ export const mediaResponseSchema = Joi.object({ thumbnail: Joi.string().uri().optional().allow(""), caption: Joi.string().optional().allow(""), group: Joi.string().optional(), + assetId: Joi.string().optional(), + takenAt: Joi.date().iso().optional(), + width: Joi.number().optional(), + height: Joi.number().optional(), + durationMs: Joi.number().optional(), }); export const mediaListItemResponseSchema = Joi.object({ @@ -70,3 +140,18 @@ export const mediaSizeResponseSchema = Joi.object({ storage: Joi.number().required(), maxStorage: Joi.number().required(), }); + +export const timelineResponseSchema = Joi.object({ + items: Joi.array() + .items( + (mediaListItemResponseSchema as Joi.ObjectSchema).keys({ + takenAt: Joi.date().iso().allow(null).required(), + assetId: Joi.string().optional(), + width: Joi.number().optional(), + height: Joi.number().optional(), + durationMs: Joi.number().optional(), + }), + ) + .required(), + nextCursor: Joi.string().allow(null).required(), +}); diff --git a/apps/api/src/media/service.ts b/apps/api/src/media/service.ts index edf6ce14..b4d538bf 100644 --- a/apps/api/src/media/service.ts +++ b/apps/api/src/media/service.ts @@ -41,12 +41,23 @@ import { createMedia, getTotalSpace, } from "./queries"; -import { listMediaRecordsByIds, sealMediaRecord } from "@/db"; +import { + findMediaByAssetIds, + isUniqueViolation, + listMediaRecordsByIds, + listTimeline, + MEDIA_ASSET_CONSTRAINT, + sealMediaRecord, + timelineKey, +} from "@/db"; +import { AssetExistsError } from "./errors"; +import { probeMedia } from "./utils/probe"; +import { decodeTimelineCursor, encodeTimelineCursor } from "./timeline-cursor"; import * as presignedUrlService from "../signature/service"; import getTags from "./utils/get-tags"; import { getPublicFileUrl, getThumbnailUrl } from "./utils/get-public-urls"; import { AccessControl, Constants, MediaWithUserId } from "@medialit/models"; -import { MediaResponse } from "./schemas"; +import { MediaResponse, TimelineItemResponse } from "./schemas"; const generateAndUploadThumbnail = async ({ workingDirectory, @@ -96,6 +107,8 @@ interface UploadProps { caption: string; group?: string; signature?: string; + assetId?: string; + takenAt?: Date; } async function upload({ @@ -106,6 +119,8 @@ async function upload({ caption, group, signature, + assetId, + takenAt, }: UploadProps): Promise { const fileName = generateFileName(file.name); const mediaSettings = await getMediaSettings(userId, appId); @@ -145,6 +160,11 @@ async function upload({ await putObject(uploadParams); + const { width, height, durationMs } = await probeMedia( + mainFilePath, + mimeType, + ); + let isThumbGenerated = false; try { isThumbGenerated = await generateAndUploadThumbnail({ @@ -181,8 +201,19 @@ async function upload({ : Constants.AccessControl.PRIVATE, group, temp: true, + assetId, + takenAt, + width, + height, + durationMs, }; - const media = await createMedia(mediaObject); + const media = await createMediaForAsset(mediaObject, { + userId, + appId, + mediaId: fileName.name, + fileName: mediaObject.fileName, + thumbnailGenerated: isThumbGenerated, + }); if (signature) { presignedUrlService.cleanup(userId, signature).catch((err: any) => { @@ -196,6 +227,158 @@ async function upload({ return media.mediaId; } +/** + * Creates the record, and if another upload took the asset ID first, removes + * the objects this one already stored so they are not orphaned. Used by every + * upload path, which all store objects before they write the record. + */ +export async function createMediaForAsset( + mediaObject: MediaWithUserId, + stored: { + userId: string; + appId: string; + mediaId: string; + fileName: string; + thumbnailGenerated: boolean; + }, +): Promise { + try { + return await createMedia(mediaObject); + } catch (err) { + if ( + !mediaObject.assetId || + !isUniqueViolation(err, MEDIA_ASSET_CONSTRAINT) + ) { + throw err; + } + await removeStoredFiles({ + ...mediaObject, + fileName: stored.fileName, + mediaId: stored.mediaId, + thumbnailGenerated: stored.thumbnailGenerated, + temp: true, + accessControl: Constants.AccessControl.PRIVATE, + }).catch((cleanupError) => + logger.error( + { err: cleanupError, mediaId: stored.mediaId }, + "Failed to remove the files of a duplicate upload", + ), + ); + const [existing] = await findAssets({ + userId: stored.userId, + appId: stored.appId, + assetIds: [mediaObject.assetId], + }); + throw new AssetExistsError(existing?.mediaId, existing?.sealed); + } +} + +export type AssetStatus = { + assetId: string; + mediaId: string; + sealed: boolean; +}; + +/** Which of these asset IDs the app already has, sealed or not. */ +async function findAssets({ + userId, + appId, + assetIds, +}: { + userId: string; + appId: string; + assetIds: string[]; +}): Promise { + const rows = await findMediaByAssetIds({ + userId: String(userId), + appId, + assetIds, + }); + return rows.map((row) => ({ + assetId: row.assetId!, + mediaId: row.mediaId, + sealed: !row.temp, + })); +} + +async function getTimeline({ + userId, + appId, + limit, + cursor, + kind, + access, + group, +}: { + userId: string; + appId: string; + limit: number; + /** From a previous page. Throws if it is not one this server issued. */ + cursor?: string; + kind?: GetPageProps["kind"]; + access?: AccessControl; + group?: string; +}): Promise<{ items: TimelineItemResponse[]; nextCursor: string | null }> { + let before; + if (cursor) { + before = decodeTimelineCursor(cursor); + if (!before) throw new InvalidCursorError(); + } + // One extra row says whether another page follows. + const rows = await listTimeline({ + userId: String(userId), + appId, + limit: limit + 1, + before: before ?? undefined, + kind, + group, + access: + access === Constants.AccessControl.PRIVATE + ? "private" + : access + ? "public" + : undefined, + }); + const page = rows.slice(0, limit); + const last = page[page.length - 1]; + return { + items: page.map( + (media): TimelineItemResponse => ({ + mediaId: media.mediaId, + originalFileName: media.originalFileName, + mimeType: media.mimeType, + size: media.size, + access: + media.accessControl === Constants.AccessControl.PRIVATE + ? Constants.AccessControl.PRIVATE + : Constants.AccessControl.PUBLIC, + thumbnail: media.thumbnailGenerated + ? getThumbnailUrl(media) + : "", + caption: media.caption, + group: media.group, + createdAt: media.createdAt, + takenAt: media.takenAt ?? null, + assetId: media.assetId, + width: media.width, + height: media.height, + durationMs: media.durationMs, + }), + ), + nextCursor: + rows.length > limit && last + ? encodeTimelineCursor({ at: timelineKey(last), id: last.id }) + : null, + }; +} + +export class InvalidCursorError extends Error { + constructor() { + super("Invalid cursor"); + this.name = "InvalidCursorError"; + } +} + type MappedMedia = Partial< Omit, "thumbnailGenerated"> > & { @@ -319,6 +502,11 @@ async function getMediaResponse( thumbnail: thumbnailUrl, caption: media.caption, group: media.group, + assetId: media.assetId, + takenAt: media.takenAt, + width: media.width, + height: media.height, + durationMs: media.durationMs, }; } @@ -558,6 +746,8 @@ async function sealMedia({ const mediaService = { upload, getPage, + getTimeline, + findAssets, getMediaCount: getMediaCountWithFilter, getMediaDetails, deleteMedia, diff --git a/apps/api/src/media/storage-middleware.ts b/apps/api/src/media/storage-middleware.ts index 9208583f..ffb74f11 100644 --- a/apps/api/src/media/storage-middleware.ts +++ b/apps/api/src/media/storage-middleware.ts @@ -111,7 +111,7 @@ const uploadTempDir = path.resolve( ); /** Deletes an upload's temp file, but never anything outside the temp folder. */ -async function removeTempFile(filePath: string | undefined) { +export async function removeTempFile(filePath: string | undefined) { if (!filePath) return; const resolved = path.resolve(filePath); if (!resolved.startsWith(uploadTempDir + path.sep)) return; diff --git a/apps/api/src/media/timeline-cursor.test.ts b/apps/api/src/media/timeline-cursor.test.ts new file mode 100644 index 00000000..74482b08 --- /dev/null +++ b/apps/api/src/media/timeline-cursor.test.ts @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { decodeTimelineCursor, encodeTimelineCursor } from "./timeline-cursor"; + +describe("timeline cursor", () => { + test("round-trips a position", () => { + const at = new Date("2026-10-30T14:02:11.123Z"); + assert.deepEqual( + decodeTimelineCursor(encodeTimelineCursor({ at, id: "abc-1" })), + { at, id: "abc-1" }, + ); + }); + + test("rejects anything this server did not issue", () => { + const forged = (value: unknown) => + Buffer.from(JSON.stringify(value)).toString("base64url"); + for (const bad of [ + "", + "not a cursor", + forged("string"), + forged(["2026-01-01T00:00:00.000Z"]), + forged(["yesterday", "id"]), + forged(["2026-01-01T00:00:00.000Z", ""]), + forged(["2026-01-01T00:00:00.000Z", 7]), + ]) { + assert.equal(decodeTimelineCursor(bad), null, bad); + } + }); +}); diff --git a/apps/api/src/media/timeline-cursor.ts b/apps/api/src/media/timeline-cursor.ts new file mode 100644 index 00000000..527a3d7e --- /dev/null +++ b/apps/api/src/media/timeline-cursor.ts @@ -0,0 +1,29 @@ +export type TimelineCursor = { at: Date; id: string }; + +/** Opaque to clients: where the last item of a page sits in the timeline. */ +export function encodeTimelineCursor(cursor: TimelineCursor): string { + return Buffer.from( + JSON.stringify([cursor.at.toISOString(), cursor.id]), + ).toString("base64url"); +} + +/** `null` for anything this server did not issue. */ +export function decodeTimelineCursor(value: string): TimelineCursor | null { + try { + const parsed = JSON.parse( + Buffer.from(value, "base64url").toString("utf8"), + ); + if (!Array.isArray(parsed) || parsed.length !== 2) return null; + const [at, id] = parsed; + if (typeof at !== "string" || typeof id !== "string" || !id) { + return null; + } + const date = new Date(at); + if (Number.isNaN(date.getTime()) || date.toISOString() !== at) { + return null; + } + return { at: date, id }; + } catch { + return null; + } +} diff --git a/apps/api/src/media/utils/probe.test.ts b/apps/api/src/media/utils/probe.test.ts new file mode 100644 index 00000000..68790622 --- /dev/null +++ b/apps/api/src/media/utils/probe.test.ts @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, test } from "node:test"; +import { parseProbeOutput, probeMedia } from "./probe"; + +describe("parseProbeOutput", () => { + test("reads size and duration", () => { + assert.deepEqual( + parseProbeOutput( + JSON.stringify({ + streams: [{ width: 1920, height: 1080 }], + format: { duration: "4.2" }, + }), + ), + { width: 1920, height: 1080, durationMs: 4200 }, + ); + }); + + test("swaps the sides of a video recorded sideways", () => { + for (const rotation of [90, -90, 270]) { + assert.deepEqual( + parseProbeOutput( + JSON.stringify({ + streams: [ + { + width: 1920, + height: 1080, + side_data_list: [{ rotation }], + }, + ], + format: { duration: "1" }, + }), + ), + { width: 1080, height: 1920, durationMs: 1000 }, + ); + } + assert.equal( + parseProbeOutput( + JSON.stringify({ + streams: [ + { + width: 1920, + height: 1080, + side_data_list: [{ rotation: 180 }], + }, + ], + }), + ).width, + 1920, + ); + }); + + test("tolerates missing values and garbage", () => { + assert.deepEqual(parseProbeOutput("not json"), {}); + assert.deepEqual( + parseProbeOutput( + JSON.stringify({ + streams: [{ width: 0, height: "N/A" }], + format: { duration: "N/A" }, + }), + ), + { width: undefined, height: undefined, durationMs: undefined }, + ); + }); +}); + +describe("probeMedia", () => { + test("probes a real image and video, and skips other files", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "probe-")); + try { + const image = path.join(dir, "a.png"); + const video = path.join(dir, "a.mp4"); + execFileSync("ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=red:s=64x48", + "-frames:v", + "1", + image, + ]); + execFileSync("ffmpeg", [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=blue:s=80x60:d=2:r=10", + "-pix_fmt", + "yuv420p", + video, + ]); + assert.deepEqual(await probeMedia(image, "image/png"), { + width: 64, + height: 48, + }); + const clip = await probeMedia(video, "video/mp4"); + assert.equal(clip.width, 80); + assert.equal(clip.height, 60); + assert.ok( + clip.durationMs! >= 1900 && clip.durationMs! <= 2200, + String(clip.durationMs), + ); + + const text = path.join(dir, "a.txt"); + writeFileSync(text, "hello"); + assert.deepEqual(await probeMedia(text, "text/plain"), {}); + assert.deepEqual(await probeMedia(text, "image/png"), {}); + assert.deepEqual( + await probeMedia(path.join(dir, "missing.png"), "image/png"), + {}, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/api/src/media/utils/probe.ts b/apps/api/src/media/utils/probe.ts new file mode 100644 index 00000000..ba1cb74f --- /dev/null +++ b/apps/api/src/media/utils/probe.ts @@ -0,0 +1,94 @@ +import { spawn } from "child_process"; + +export type MediaProbe = { + width?: number; + height?: number; + durationMs?: number; +}; + +const PROBE_TIMEOUT_MS = 15_000; + +function positiveInteger(value: unknown): number | undefined { + const number = Number(value); + return Number.isFinite(number) && number > 0 + ? Math.round(number) + : undefined; +} + +/** Reads what ffprobe printed as JSON. Exported for tests. */ +export function parseProbeOutput(output: string): MediaProbe { + let parsed: any; + try { + parsed = JSON.parse(output); + } catch { + return {}; + } + const stream = parsed?.streams?.[0]; + let width = positiveInteger(stream?.width); + let height = positiveInteger(stream?.height); + // A phone records sideways and flags the file as rotated; people see it + // upright, so report it that way. + const rotation = Number( + stream?.side_data_list?.find((entry: any) => "rotation" in entry) + ?.rotation ?? stream?.tags?.rotate, + ); + if (Number.isFinite(rotation) && Math.abs(rotation) % 180 === 90) { + [width, height] = [height, width]; + } + const seconds = Number(parsed?.format?.duration ?? stream?.duration); + return { + width, + height, + durationMs: + Number.isFinite(seconds) && seconds > 0 + ? Math.round(seconds * 1000) + : undefined, + }; +} + +/** + * Width, height and, for video, duration of an image or video. Never throws: + * a file ffprobe cannot read just has no dimensions. + */ +export function probeMedia( + filePath: string, + mimeType: string, +): Promise { + const isVideo = /^video\//i.test(mimeType); + if (!isVideo && !/^image\//i.test(mimeType)) return Promise.resolve({}); + return new Promise((resolve) => { + // No shell: the path is derived from a client-controlled extension. + const child = spawn( + "ffprobe", + [ + "-v", + "error", + "-select_streams", + "v:0", + "-print_format", + "json", + "-show_entries", + "stream=width,height,duration:stream_side_data=rotation:stream_tags=rotate:format=duration", + filePath, + ], + { stdio: ["ignore", "pipe", "ignore"] }, + ); + let output = ""; + const timer = setTimeout(() => child.kill("SIGKILL"), PROBE_TIMEOUT_MS); + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.on("error", () => { + clearTimeout(timer); + resolve({}); + }); + child.on("close", (code) => { + clearTimeout(timer); + const probe: MediaProbe = + code === 0 ? parseProbeOutput(output) : {}; + // A still image has no duration worth keeping. + if (!isVideo) delete probe.durationMs; + resolve(probe); + }); + }); +} diff --git a/apps/api/src/swagger-generator.ts b/apps/api/src/swagger-generator.ts index 1a211be3..c3085c43 100644 --- a/apps/api/src/swagger-generator.ts +++ b/apps/api/src/swagger-generator.ts @@ -8,6 +8,9 @@ import { getMediaSchema, mediaResponseSchema, mediaListItemResponseSchema, + assetLookupSchema, + assetLookupResponseSchema, + timelineResponseSchema, mediaCountResponseSchema, mediaSizeResponseSchema, } from "./media/schemas"; @@ -97,6 +100,8 @@ swaggerAutogen()(outputFile, routes, doc).then(() => { "post /media/get/size": "getMediaSize", "post /media/get/{mediaId}": "getMediaDetails", "post /media/get": "listMedia", + "post /media/get/assets": "lookupAssets", + "get /media/timeline": "getMediaTimeline", "post /media/seal/{mediaId}": "sealMedia", "delete /media/delete/{mediaId}": "deleteMedia", }; @@ -119,6 +124,18 @@ swaggerAutogen()(outputFile, routes, doc).then(() => { UploadMediaPayload: joiToSwagger(uploadMediaSchema).swagger, GetMediaQuery: joiToSwagger(getMediaSchema).swagger, SignatureResponse: joiToSwagger(signatureResponseSchema).swagger, + AssetLookupPayload: joiToSwagger(assetLookupSchema).swagger, + AssetLookupResponse: joiToSwagger(assetLookupResponseSchema).swagger, + TimelineResponse: joiToSwagger(timelineResponseSchema).swagger, + AssetExistsResponse: { + type: "object", + properties: { + error: { type: "string", example: "asset_exists" }, + mediaId: { type: "string" }, + sealed: { type: "boolean" }, + }, + required: ["error"], + }, MediaCountResponse: joiToSwagger(mediaCountResponseSchema).swagger, MediaSizeResponse: joiToSwagger(mediaSizeResponseSchema).swagger, ErrorResponse: { diff --git a/apps/api/src/swagger_output.json b/apps/api/src/swagger_output.json index bee22a91..a594405a 100644 --- a/apps/api/src/swagger_output.json +++ b/apps/api/src/swagger_output.json @@ -314,6 +314,10 @@ "summary": "Upload Media", "description": "Upload a new media file. Use OAuth Bearer auth, API key auth from Authorize (`x-medialit-apikey`), or pass `x-medialit-signature` for this endpoint only.", "parameters": [ + { + "name": "content-length", + "in": "header" + }, { "name": "x-medialit-signature", "in": "header", @@ -374,6 +378,16 @@ } } }, + "409": { + "description": "The app already has a file with this `assetId`. Seal it if `sealed` is false.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetExistsResponse" + } + } + } + }, "500": { "description": "Internal Server Error", "content": { @@ -422,6 +436,15 @@ }, "group": { "type": "string" + }, + "assetId": { + "type": "string", + "description": "Your own ID for the source of this file. Unique per app, so retrying the same upload cannot create a duplicate." + }, + "takenAt": { + "type": "string", + "format": "date-time", + "description": "When the content was captured. Orders the timeline." } }, "required": [ @@ -580,6 +603,247 @@ "operationId": "getMediaSize" } }, + "/media/get/assets": { + "post": { + "tags": [ + "Media" + ], + "summary": "Look Up Assets", + "description": "Tells which of your `assetId`s the app already has, sealed or not. Call it before uploading a batch to skip what is already there.", + "responses": { + "200": { + "description": "The assets that exist", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetLookupResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Bad Request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Internal Server Error" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetLookupPayload" + } + } + } + }, + "operationId": "lookupAssets" + } + }, + "/media/timeline": { + "get": { + "tags": [ + "Media" + ], + "summary": "Media Timeline", + "description": "Sealed media, newest first by `takenAt`, or by `createdAt` where there is no `takenAt`. Follow `nextCursor` for the next page; pages stay stable while uploads land.", + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "Items per page, 1 to 200. Defaults to 50.", + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "minimum": { + "type": "number", + "example": 1 + }, + "maximum": { + "type": "number", + "example": 200 + } + } + } + }, + { + "name": "cursor", + "in": "query", + "description": "The nextCursor from the previous page", + "schema": { + "type": "string" + } + }, + { + "name": "kind", + "in": "query", + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "image", + "video", + "pdf", + "other" + ], + "items": { + "type": "string" + } + } + } + } + }, + { + "name": "access", + "in": "query", + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "public", + "private" + ], + "items": { + "type": "string" + } + } + } + } + }, + { + "name": "group", + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "A page of the timeline", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimelineResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Bad Request" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Unauthorized" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "error": "Internal Server Error" + } + } + } + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "apiKeyAuth": [] + } + ], + "operationId": "getMediaTimeline" + } + }, "/media/get/{mediaId}": { "post": { "tags": [ @@ -1003,6 +1267,25 @@ }, "group": { "type": "string" + }, + "assetId": { + "type": "string" + }, + "takenAt": { + "type": "string", + "format": "date-time" + }, + "width": { + "type": "number", + "format": "float" + }, + "height": { + "type": "number", + "format": "float" + }, + "durationMs": { + "type": "number", + "format": "float" } }, "required": [ @@ -1127,6 +1410,14 @@ }, "group": { "type": "string" + }, + "assetId": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,200}$" + }, + "takenAt": { + "type": "string", + "format": "date-time" } }, "additionalProperties": false @@ -1170,6 +1461,150 @@ ], "additionalProperties": false }, + "AssetLookupPayload": { + "type": "object", + "properties": { + "assetIds": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[A-Za-z0-9._:-]{1,200}$" + }, + "minItems": 1, + "maxItems": 200 + } + }, + "required": [ + "assetIds" + ], + "additionalProperties": false + }, + "AssetLookupResponse": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assetId": { + "type": "string" + }, + "mediaId": { + "type": "string" + }, + "sealed": { + "type": "boolean" + } + }, + "required": [ + "assetId", + "mediaId", + "sealed" + ], + "additionalProperties": false + } + }, + "TimelineResponse": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "mediaId": { + "type": "string" + }, + "originalFileName": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "size": { + "type": "number", + "format": "float" + }, + "access": { + "type": "string", + "enum": [ + "public", + "private" + ] + }, + "thumbnail": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "group": { + "type": "string" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "takenAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "assetId": { + "type": "string" + }, + "width": { + "type": "number", + "format": "float" + }, + "height": { + "type": "number", + "format": "float" + }, + "durationMs": { + "type": "number", + "format": "float" + } + }, + "required": [ + "mediaId", + "originalFileName", + "mimeType", + "size", + "access", + "createdAt", + "takenAt" + ], + "additionalProperties": false + } + }, + "nextCursor": { + "type": "string", + "nullable": true + } + }, + "required": [ + "items", + "nextCursor" + ], + "additionalProperties": false + }, + "AssetExistsResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "asset_exists" + }, + "mediaId": { + "type": "string" + }, + "sealed": { + "type": "boolean" + } + }, + "required": [ + "error" + ] + }, "MediaCountResponse": { "type": "object", "properties": { diff --git a/apps/api/src/tus/finalize.ts b/apps/api/src/tus/finalize.ts index 1a191f8e..877bc0e6 100644 --- a/apps/api/src/tus/finalize.ts +++ b/apps/api/src/tus/finalize.ts @@ -25,7 +25,8 @@ import logger from "../services/log"; import generateKey from "../media/utils/generate-key"; import { getMediaSettings } from "../media-settings/queries"; import generateFileName from "../media/utils/generate-file-name"; -import { createMedia } from "../media/queries"; +import { createMediaForAsset } from "../media/service"; +import { probeMedia } from "../media/utils/probe"; import getTags from "../media/utils/get-tags"; import { getTusUpload, markTusUploadComplete } from "./queries"; import * as presignedUrlService from "../signature/service"; @@ -117,6 +118,11 @@ export default async function finalizeUpload( await putObject(uploadParams); + const { width, height, durationMs } = await probeMedia( + mainFilePath, + mimeType, + ); + let isThumbGenerated = false; try { isThumbGenerated = await generateAndUploadThumbnail({ @@ -155,8 +161,19 @@ export default async function finalizeUpload( : Constants.AccessControl.PRIVATE, group: metadata.group, temp: true, + assetId: metadata.assetId, + takenAt: metadata.takenAt ? new Date(metadata.takenAt) : undefined, + width, + height, + durationMs, } as MediaWithUserId; - const media = await createMedia(mediaObject); + const media = await createMediaForAsset(mediaObject, { + userId, + appId, + mediaId: fileName.name, + fileName: mediaObject.fileName, + thumbnailGenerated: isThumbGenerated, + }); // Mark upload as complete await markTusUploadComplete(uploadId); diff --git a/apps/api/src/tus/queries.ts b/apps/api/src/tus/queries.ts index 2954705d..35287e73 100644 --- a/apps/api/src/tus/queries.ts +++ b/apps/api/src/tus/queries.ts @@ -18,7 +18,11 @@ export interface TusUpload { metadata: Pick< Media, "fileName" | "mimeType" | "accessControl" | "caption" | "group" - >; + > & { + assetId?: string; + /** ISO 8601, already validated. */ + takenAt?: string; + }; tempFilePath: string; isComplete: boolean; expiresAt?: Date; @@ -31,6 +35,8 @@ function toTusMetadata(metadata: { accessControl: string; caption?: string; group?: string; + assetId?: string; + takenAt?: string; }): TusUpload["metadata"] { return { fileName: metadata.fileName, @@ -39,6 +45,8 @@ function toTusMetadata(metadata: { metadata.accessControl === "public" ? "public" : "private", caption: metadata.caption, group: metadata.group, + assetId: metadata.assetId, + takenAt: metadata.takenAt, }; } @@ -59,6 +67,8 @@ export async function createTusUpload( accessControl: data.metadata.accessControl, caption: data.metadata.caption, group: data.metadata.group, + assetId: data.metadata.assetId, + takenAt: data.metadata.takenAt, }, tempFilePath: data.tempFilePath, signature: data.signature, diff --git a/apps/api/src/tus/tus-server.ts b/apps/api/src/tus/tus-server.ts index 1fcf5065..773008b8 100644 --- a/apps/api/src/tus/tus-server.ts +++ b/apps/api/src/tus/tus-server.ts @@ -16,6 +16,8 @@ import { import { validateUploadConstraints } from "../media/storage-middleware"; import { createTusUpload, updateTusUploadOffset } from "./queries"; import mediaService from "../media/service"; +import { AssetExistsError } from "../media/errors"; +import { assetIdSchema, takenAtSchema } from "../media/schemas"; const store = new FileStore({ directory: `${tempFileDirForUploads}/tus-uploads`, @@ -61,6 +63,12 @@ export const server = new Server({ { err, uploadId: upload.id }, "Error finalizing tus upload", ); + if (err instanceof AssetExistsError) { + throw { + status_code: 409, + body: JSON.stringify(err.toBody()), + }; + } throw { status_code: 403, body: err.message, @@ -80,11 +88,51 @@ export function uploadGroup( return signatureGroup ?? clientGroup; } +/** `assetId` and `takenAt` from the TUS metadata, or the error to send. */ +export function parseAssetMetadata( + metadata: Record, +): { assetId?: string; takenAt?: string } | TusError { + const assetId = metadata.assetId || undefined; + const takenAt = metadata.takenAt || undefined; + if (assetId !== undefined) { + const { error } = assetIdSchema.validate(assetId); + if (error) return { status_code: 400, body: error.message }; + } + if (takenAt !== undefined) { + const { error, value } = takenAtSchema.validate(takenAt); + if (error) return { status_code: 400, body: error.message }; + return { assetId, takenAt: (value as Date).toISOString() }; + } + return { assetId }; +} + export async function handleTusUploadCreate(req: any, upload: any) { const metadata = upload.metadata; const { user, appId, group } = req; try { + const asset = parseAssetMetadata(metadata); + if ("status_code" in asset) throw asset; + if (asset.assetId) { + // Refuse before any bytes are sent, and say what is there. + const [existing] = await mediaService.findAssets({ + userId: user.id, + appId: appId!, + assetIds: [asset.assetId], + }); + if (existing) { + throw { + status_code: 409, + body: JSON.stringify( + new AssetExistsError( + existing.mediaId, + existing.sealed, + ).toBody(), + ), + }; + } + } + const validation = await validateUploadConstraints({ size: upload.size, user, @@ -107,6 +155,8 @@ export async function handleTusUploadCreate(req: any, upload: any) { accessControl: metadata.access, caption: metadata.caption, group: uploadGroup(group, metadata.group), + assetId: asset.assetId, + takenAt: asset.takenAt, }, tempFilePath: upload.id, }); diff --git a/docs/prds/mobile-app-prd.md b/docs/prds/mobile-app-prd.md new file mode 100644 index 00000000..6581e153 --- /dev/null +++ b/docs/prds/mobile-app-prd.md @@ -0,0 +1,321 @@ +# MediaLit Mobile App + +**Status:** Draft. Phase 1 is partly built: §5.2 to §5.4 are implemented (asset +IDs, capture time and dimensions, the timeline and asset lookup endpoints). §5.1, +§5.5 and §5.6 are not started. +**Author:** Rajat Saxena +**Date:** 2026-11-02 + +## 1. Objective + +Ship an official iOS and Android app for MediaLit Cloud (`api.medialit.cloud`) +that lets people: + +- Browse and view their files on the go. +- Upload files by hand. +- Back up their camera roll automatically, the way Google Photos does. + +The app is a thin client. The API owns product behavior, as it does for the web +dashboard. Everything the app needs that the API cannot do today is added to +the API first, and every API change is additive: MediaLit is the shared file +backend for CourseLit, SendLit and FrontLit, and none of them may notice. + +## 2. Decisions + +| # | Decision | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | v1 is browse plus manual upload. Automatic camera-roll backup follows in a later phase (§8). | +| 2 | iOS and Android from the start, built with Expo (React Native, TypeScript). | +| 3 | Camera photos are private by default. Their thumbnails stay off the public bucket, behind an opt-in per-app setting so the public thumbnails the CMS products use keep working. | +| 4 | The phone syncs into a dedicated app named **Camera Roll**, created if the user does not have one. | +| 5 | The app targets `api.medialit.cloud` only. There is no server URL setting and no self-hosting support. Self-hosted instances keep using the web dashboard, API and CLI. | + +## 3. Non-goals for v1 + +- Albums, sharing links, search, face or place recognition. +- "Free up space" (deleting local copies after backup). +- Cross-device change feeds and delete tombstones (§9). +- Offline editing, in-app photo editing. +- Tablet-specific layouts. + +## 4. Current state + +What the app can build on: + +- **Login.** The API is an OAuth server (Better Auth, authorization code with + PKCE). Scopes `data:read`, `data:write` and `offline_access` exist, the hosted + login is email OTP, and the login page works in an in-app browser. A token is + bound to one app through the `app_id` claim, chosen at consent + (`auth/oauth-app-selection.ts`). +- **Bearer auth on the data routes.** `/media/get`, `/media/get/:mediaId`, + `/media/seal/:mediaId`, `/media/delete/:mediaId`, `/media/create` and the TUS + endpoint `/media/create/resumable` all accept `Authorization: Bearer`. +- **Resumable uploads.** TUS takes `fileName`, `mimeType`, `access`, `caption` + and `group` as metadata. An upload stays `temp` until `POST /media/seal/:id`, + which also checks quota. + +What is missing for a camera roll: + +1. **No capture time.** `media` has only `createdAt`, the upload time. A + backfilled library would sort in upload order. +2. **No idempotency.** Nothing identifies a local asset on the server. A crash + between the TUS finish and the seal, or a reinstall, creates duplicates. +3. **No suitable listing.** Bearer `/media/get` returns a bare array paged by + `page` and `limit` (offsets shift while uploads run). `search`, `kind` and + `sort` exist in `mediaService.getPage` but only the cookie-session dashboard + routes expose them. +4. **No layout metadata.** Width, height and video duration are not stored, so a + grid cannot reserve space before images load. +5. **Thumbnails do not fit photos.** + - Thumbnails are generated for JPEG, PNG, WebP and video only + (`config/constants.ts`). HEIC, the iPhone default, gets none. + - `bin/generate-thumbnail.sh` center-crops every image to 16:9, so a + portrait photo becomes a narrow strip. +6. **Private thumbnails are public.** On seal, thumbnails of private files are + copied to the public bucket (`media/service.ts`, `sealMedia`). The URLs are + unguessable but need no authentication. + +## 5. API changes + +All changes are additive, documented with the swagger annotations, and covered +by tests. Anything that touches an MCP tool also updates +`packages/integration-tests/src/mcp.ts`, as `apps/api/AGENTS.md` requires. + +### 5.1 Camera Roll app, provisioned at authorization + +The mobile app is a first-party public client with a fixed `client_id` and +registered redirect URIs, not a dynamically registered one. When a user +authorizes that client: + +1. Find the user's non-deleted app named `Camera Roll`. If there is none, + create it with its media settings (§5.5) turned on. +2. Select it as the token's app and skip the app picker. + +Every other client keeps today's picker. The API owns this, so the app never +creates apps itself (app creation is cookie-session only today). + +Edge cases: + +- A user who already made an app called `Camera Roll` by hand gets that app. The + unique index on `(user_id, name)` makes a second one impossible anyway. +- If the user deletes the app, the next request gets `invalid_token` + (`resolveAuth` already does this). The app re-authorizes and the API + recreates it. + +To verify during implementation: the post-login hook only receives the user and +session, so the client ID may have to come from the `oauth_query` the picker +page already reads. Also verify how the OAuth provider registers a trusted +public client, and whether billing caps the number of apps per account. + +### 5.2 Asset identity and idempotency + +Add `media.asset_id` (nullable text) with a unique index on +`(app_id, asset_id) WHERE asset_id IS NOT NULL`. Accept `assetId` on +`POST /media/create` and as TUS metadata. + +- Creating an upload whose `assetId` already exists in the app is rejected with + `409 { error: "asset_exists", mediaId, sealed }`. A client that crashed after + the upload then only needs to seal. +- `POST /media/get/assets` takes `{ assetIds: string[] }` (up to 200) and + returns `[{ assetId, mediaId, sealed }]` for the ones that exist. The client + calls it before uploading a batch. +- Expired temp uploads are already deleted by `cleanupExpiredTempUploads`, which + frees their `assetId` for a retry. + +The server treats `assetId` as an opaque string. The client builds it (§7.4). + +Optional: a `seal` flag (REST field and TUS metadata) that seals on finalize for +authenticated uploads. It is needed only if the background-upload spike (§8, +phase 4) shows that no JS runs after a background upload completes. + +### 5.3 Capture time and layout metadata + +Add nullable columns `taken_at`, `width`, `height` and `duration_ms`. + +- `takenAt` comes from the client (asset creation time) as an ISO string in the + upload metadata or form field. The server rejects values in the future by more + than a day and treats it as untrusted for authorization, only for ordering. +- `width`, `height` and `duration_ms` are probed on the server during upload + processing with `ffprobe`, which `generate-thumbnail.sh` already uses. +- Existing rows keep `NULL` and sort by `created_at`. + +### 5.4 Timeline endpoint + +`GET /media/timeline?limit=&cursor=&kind=&group=`, with Bearer or API key and +the `data:read` scope. + +```json +{ + "items": [ + { + "mediaId": "…", + "originalFileName": "IMG_0001.HEIC", + "mimeType": "image/heic", + "size": 2481234, + "access": "private", + "thumbnail": "https://…", + "takenAt": "2026-10-30T14:02:11.000Z", // null if the uploader sent none + "createdAt": "2026-11-02T09:00:00.000Z", + "width": 4032, + "height": 3024, + "durationMs": null, + "assetId": "…" + } + ], + "nextCursor": "opaque-or-null" +} +``` + +- Sealed items only, sorted by `COALESCE(taken_at, created_at) DESC, id DESC`. +- Keyset pagination. The cursor is an opaque encoding of the last item's sort + key and id, so uploads in progress never shift pages. `limit` is 1 to 200. +- Needs an index on `(app_id, temp, COALESCE(taken_at, created_at) DESC, id DESC)`. +- The existing `POST /media/get` is untouched. Its response is a bare array and + cannot carry a cursor without breaking callers. +- The full-size `file` URL stays on `POST /media/get/:mediaId`. The app fetches + it when the viewer opens. + +### 5.5 Private thumbnails and thumbnail shape: opt-in settings + +Two new per-app `media_settings` fields, both defaulting to the current +behavior so nothing changes for existing apps: + +| Setting | Default | Camera Roll | Effect | +| ------------------- | ------- | ----------- | ------------------------------------------------------------------------------------------------------ | +| `privateThumbnails` | `false` | `true` | On seal, thumbnails of **private** files stay in the private bucket and are returned as signed URLs. | +| `thumbnailFit` | `crop` | `contain` | `crop` keeps today's 16:9 center crop. `contain` keeps the aspect ratio with a longest edge of 480 px. | + +Details: + +- Public files always get public thumbnails, whatever the setting. +- Record where each thumbnail lives in a new `media.thumbnail_private` boolean, + set at seal. `removeStoredFiles` and `getMediaResponse` read it instead of + inferring the bucket from `temp`, so flipping the setting later cannot orphan + or mis-delete files. Flipping it does not migrate existing thumbnails. +- `getPage` (the dashboard and `/media/get`) must sign private thumbnail URLs + for rows with `thumbnail_private`. Signing is local computation, not a network + call. +- Both settings are exposed through `/settings/media` and the MCP settings tool, + with integration-test coverage. +- Signed URLs expire, so the app caches images by `mediaId`, never by URL. + +### 5.6 HEIC and HEIF thumbnails + +Extend thumbnail generation to `image/heic` and `image/heif`. + +- Verify whether the `ffmpeg` in the API image decodes HEIC. If it does not, + add a decoder (for example `libheif` tools) to the Dockerfile and convert to a + JPEG intermediate before the thumbnail step. +- The original HEIC is stored untouched. Only the thumbnail is converted. +- Check other camera formats (DNG, Live Photo video, 10-bit HEVC) as part of the + same pass. + +### 5.7 Compatibility and tests + +- Unit tests for the new fields, the 409 path, cursor paging at ties, and the + setting defaults. +- Integration tests in `packages/integration-tests` for the new REST routes, + and MCP parity if any tool changes. +- The existing CMS upload flow (signature, seal, public thumbnail) is covered by + tests that must keep passing unchanged. + +## 6. Mobile app architecture + +- **Location:** `apps/mobile` in this monorepo, private, not published. +- **Stack:** Expo with development builds (not Expo Go), TypeScript, Expo + Router, TanStack Query for server state. +- **Native modules:** `expo-auth-session`, `expo-secure-store`, + `expo-media-library`, `expo-image` (disk cache), `expo-file-system`, + `expo-background-task` / `expo-task-manager`, `tus-js-client`. +- **API access:** a small typed client generated or written against + `/openapi.json`. No product logic lives in the app. +- **Builds:** EAS Build and EAS Submit. Needs an Apple Developer account, a + Google Play account, a privacy policy URL, and permission usage strings + (photo library read, and write if we save downloads). + +Monorepo cautions to resolve in phase 2: + +- Bun's workspace linker versus Metro's resolution. If Metro cannot follow the + default install layout, set a hoisted linker for the workspace or install + `apps/mobile` separately. Do not change install behavior for the other + packages without checking CI. +- The root `overrides` pin versions of packages such as `next`, `postcss` and + `babel`. Confirm they do not conflict with React Native's tree. +- The root ESLint config targets Next.js. Give `apps/mobile` its own scope. + +## 7. Mobile behavior + +### 7.1 Auth + +Authorization code with PKCE through an in-app browser against the hosted login +(§5.1), requesting `openid offline_access data:read data:write`. Tokens go in +secure storage. On `401 invalid_token` the app refreshes once, and if that +fails it signs the user out and re-authorizes. + +### 7.2 Screens (v1) + +- **Sign in.** One button, then the hosted page. +- **Library.** Square-tile grid grouped by day from `takenAt`, infinite scroll + on the timeline cursor, pull to refresh, loading, empty and error states. +- **Viewer.** Pinch zoom, swipe between items, video playback, details sheet + (name, size, date, dimensions), delete with confirmation, share, save to + device. +- **Upload.** Pick from the device library, queue with per-file progress, retry + and cancel. +- **Account.** Storage used versus plan limit (`POST /media/get/size`), sign + out. + +### 7.3 Upload pipeline + +1. Build the `assetId` and call `POST /media/get/assets` for the batch. +2. For missing assets, start a TUS upload with `access: private`, `assetId`, + `takenAt`, `fileName` and `mimeType`. +3. On finish, `POST /media/seal/:id`. A `403` for quota shows an "out of + storage" state and pauses the queue instead of retrying. +4. Persist the queue in local storage so it survives restarts, and resume TUS + uploads from the server offset. + +### 7.4 Asset ID + +The server does not interpret it. The local media library ID is not stable +across reinstalls or devices, so the client derives it from file facts: +`sha1(takenAtMs | originalFileName | size)`. This is cheap (no file read) and +good enough to avoid duplicates after a reinstall. An edited photo changes +size and uploads again, which matches what users expect. Revisit in phase 3 if +real collisions show up. + +## 8. Phases + +1. **API groundwork** (§5). Nothing ships to users. Done when the new routes, + settings and tests are merged and the CMS products' behavior is unchanged. +2. **App shell.** Sign in, Camera Roll app provisioning, library grid, viewer, + delete, storage meter. Internal TestFlight and Play internal track. +3. **Manual upload.** Picker, TUS queue, seal, quota handling, `assetId` + dedupe. Public beta. +4. **Automatic backup.** Permission flow, backup on and off, Wi-Fi only, + charging only, folder selection, status screen, resume. **Spike first:** + whether `expo-file-system` background uploads (iOS background `URLSession`, + Android WorkManager) can carry an upload with no JS running. If so, use + `POST /media/create` with `seal` in the background and TUS in the foreground + for large videos. If not, rely on foreground sync plus opportunistic + background tasks. iOS gives no guaranteed background runs, and the UI must + say so honestly ("Backup paused, open the app to continue"). +5. **Later.** Free up space, search, albums, sharing, change feed. + +## 9. Risks and open questions + +- **Deletes and re-upload.** With no tombstones, a photo deleted in the cloud + looks "missing" after a reinstall and uploads again. The device keeps a local + ledger of uploaded asset IDs to avoid this within an install. A server-side + tombstone (or soft delete) is a phase 5 item. Decide whether v1 can live with + it. +- **Storage plans.** A camera roll fills quota quickly. Check the free-plan + limit and what the app shows when backup stops. This is a pricing question, + not only an engineering one. +- **Store review.** Apple reviews photo-library access closely. The usage + strings and privacy policy need to describe backup plainly. +- **Large videos.** Check API upload size limits (`preflightUploadSize`) and + temp disk on the API host for multi-GB videos. +- **Live Photos and bursts.** v1 treats each as the still image only. +- **Signed URL lifetime.** Not yet checked. It affects how long the viewer can + stay open on a video. diff --git a/packages/integration-tests/src/rest.ts b/packages/integration-tests/src/rest.ts index 1c101c12..a432a7e9 100644 --- a/packages/integration-tests/src/rest.ts +++ b/packages/integration-tests/src/rest.ts @@ -458,6 +458,199 @@ export async function runIntegrationTests( log(`PASS delete ${mediaId} and storage increment`); } assert.deepEqual(await list({ group }), []); + + // Camera roll: uploads carry the phone's own asset ID and capture + // time, so retries cannot duplicate and the timeline orders by capture. + const assetA = `it-${randomUUID()}`; + const assetB = `it-${randomUUID()}`; + const takenA = "2021-01-01T00:00:00.000Z"; + const takenB = "2023-01-01T00:00:00.000Z"; + const authHeaders = { "x-medialit-apikey": apiKey! }; + + const form = new FormData(); + form.set( + "file", + new Blob([Uint8Array.from(png)], { type: "image/png" }), + "asset-a.png", + ); + form.set("access", "private"); + form.set("group", group); + form.set("assetId", assetA); + form.set("takenAt", takenA); + uploadsAttempted = true; + const mediaA = track( + await ( + await request(url("media/create"), { + method: "POST", + headers: authHeaders, + body: form, + }) + ).json(), + ); + assert.equal(mediaA.assetId, assetA); + assert.equal(mediaA.takenAt, takenA); + integer(mediaA.width, "width"); + integer(mediaA.height, "height"); + + const tusHeaders = { ...authHeaders, "Tus-Resumable": "1.0.0" }; + const tusMetadata = { + fileName: "asset-b.png", + mimeType: "image/png", + access: "private", + group, + assetId: assetB, + takenAt: takenB, + }; + const tusCreated = await request( + url("media/create/resumable"), + { + method: "POST", + headers: { + ...tusHeaders, + "Upload-Length": String(png.length), + "Upload-Metadata": Object.entries(tusMetadata) + .map( + ([key, value]) => + `${key} ${Buffer.from(value).toString("base64")}`, + ) + .join(","), + }, + }, + 201, + ); + const tusLocation = new URL( + text(tusCreated.headers.get("location"), "tus Location"), + url("media/create/resumable"), + ); + const tusDone = await request( + tusLocation, + { + method: "PATCH", + headers: { + ...tusHeaders, + "content-type": "application/offset+octet-stream", + "Upload-Offset": "0", + }, + body: Uint8Array.from(png), + }, + 204, + ); + const mediaB = track( + JSON.parse(text(tusDone.headers.get("media"), "tus media header")), + ); + assert.equal(mediaB.assetId, assetB); + assert.equal(mediaB.takenAt, takenB); + + // The same asset again is refused, over both transports, and says + // what is already there. + const again = new FormData(); + again.set( + "file", + new Blob([Uint8Array.from(png)], { type: "image/png" }), + "asset-a-again.png", + ); + again.set("assetId", assetA); + const conflict = object( + await ( + await request( + url("media/create"), + { method: "POST", headers: authHeaders, body: again }, + 409, + ) + ).json(), + ); + assert.equal(conflict.error, "asset_exists"); + assert.equal(conflict.mediaId, mediaA.mediaId); + assert.equal(conflict.sealed, false); + const tusConflict = await request( + url("media/create/resumable"), + { + method: "POST", + headers: { + ...tusHeaders, + "Upload-Length": String(png.length), + "Upload-Metadata": Object.entries(tusMetadata) + .map( + ([key, value]) => + `${key} ${Buffer.from(value).toString("base64")}`, + ) + .join(","), + }, + }, + 409, + ); + assert.equal( + object(JSON.parse(await tusConflict.text())).mediaId, + mediaB.mediaId, + ); + log("PASS asset IDs, capture time and duplicate refusal"); + + const lookup = async () => + ( + (await api("media/get/assets", { + assetIds: [assetA, assetB, `it-${randomUUID()}`], + })) as JsonObject[] + ) + .map(object) + .sort((left, right) => + String(left.assetId).localeCompare(String(right.assetId)), + ); + const expectedLookup = (sealed: boolean) => + [ + { assetId: assetA, mediaId: mediaA.mediaId, sealed }, + { assetId: assetB, mediaId: mediaB.mediaId, sealed }, + ].sort((left, right) => left.assetId.localeCompare(right.assetId)); + assert.deepEqual(await lookup(), expectedLookup(false)); + for (const media of [mediaA, mediaB]) { + await api( + `media/seal/${encodeURIComponent(text(media.mediaId, "mediaId"))}`, + ); + } + assert.deepEqual(await lookup(), expectedLookup(true)); + log("PASS asset lookup before and after sealing"); + + // Newest capture first, whatever the upload order. + const timelineUrl = (query: string) => + url(`media/timeline?group=${encodeURIComponent(group)}&${query}`); + const getTimeline = async (query: string, status = 200) => + object( + await ( + await request( + timelineUrl(query), + { headers: authHeaders }, + status, + ) + ).json(), + ); + const first = await getTimeline("limit=1"); + const firstItems = first.items as JsonObject[]; + assert.equal(firstItems.length, 1); + assert.equal(firstItems[0].mediaId, mediaB.mediaId); + assert.equal(firstItems[0].takenAt, takenB); + const cursor = text(first.nextCursor, "nextCursor"); + const second = await getTimeline( + `limit=1&cursor=${encodeURIComponent(cursor)}`, + ); + assert.deepEqual( + (second.items as JsonObject[]).map((item) => item.mediaId), + [mediaA.mediaId], + ); + assert.equal(second.nextCursor, null); + await getTimeline("cursor=forged", 400); + await getTimeline("limit=0", 400); + log("PASS timeline order, cursor paging and validation"); + + for (const media of [mediaA, mediaB]) { + const mediaId = text(media.mediaId, "mediaId"); + await api( + `media/delete/${encodeURIComponent(mediaId)}`, + undefined, + "DELETE", + ); + pending.delete(mediaId); + } + assert.deepEqual(await lookup(), []); + log("PASS camera roll uploads cleaned up"); } catch (error) { failure = error; } finally { diff --git a/packages/models/src/media.ts b/packages/models/src/media.ts index 5497d5e5..04db5f08 100644 --- a/packages/models/src/media.ts +++ b/packages/models/src/media.ts @@ -14,4 +14,13 @@ export interface Media { caption?: string; file?: string; temp?: boolean; + /** Client-chosen ID of the source asset; unique per app. */ + assetId?: string; + /** When the content was captured, as reported by the client. */ + takenAt?: Date; + width?: number; + height?: number; + durationMs?: number; + /** The thumbnail is in the private bucket, behind a signed URL. */ + thumbnailPrivate?: boolean; }