diff --git a/docs/configuration.md b/docs/configuration.md index 6ee6a52b..e3dc6814 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -26,6 +26,8 @@ This project keeps root-level configuration files to the minimum required by fra - `POST /api/exif-tracking/save` - `POST /exif-viewer/parse` - `POST /*/exif-viewer/parse` + - `POST /lists/under-construction` + - `POST /*/lists/under-construction` - `POST /gear/*` - `POST /*/gear/*` - Server-side BotID classification is isolated in `src/server/security/botid.ts`, which wraps `checkBotId()` and keeps request-scoped detection logic out of UI code. diff --git a/docs/developer-api.md b/docs/developer-api.md new file mode 100644 index 00000000..d230e733 --- /dev/null +++ b/docs/developer-api.md @@ -0,0 +1,78 @@ +# Developer API + +Sharply’s developer API is a gated beta for published photography gear data. It is free during the beta and currently exposes read-only endpoints under `/api/v1`. + +## Access and keys + +- An administrator enables developer access for a user from `/admin/developer-api`. +- Approved users manage up to three active keys at `/developer`. +- Approved users can review the in-product endpoint guide at `/developer/docs`. +- Keys use the `sharply_live_` prefix and are displayed only when created. The database stores a SHA-256 hash, not the secret. +- Removing developer access immediately revokes all of the user’s keys. +- Send keys with `Authorization: Bearer sharply_live_...`. Use the API from your server or another trusted backend. Browser calls are not supported because the API does not send CORS headers; never place a key in browser code or a public client bundle. + +## Rate limit and usage + +Each key is limited to 60 total requests per fixed UTC minute. Successful and application-level failed requests made by an authenticated, non-rate-limited key count toward the daily usage totals shown in the portal. Invalid-key and rate-limited requests do not count. + +Responses include `X-Request-Id`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Rate-limit errors return `429` with the standard error response. + +## Endpoints + +### `GET /api/v1/search` + +Required query parameter: `q` (2–200 characters). Optional `page` defaults to 1 and `limit` defaults to 20 (maximum 25). + +Returns a ranked page of published results: + +```json +{ + "data": [], + "pagination": { "page": 1, "limit": 20, "total": 0, "totalPages": 0 }, + "meta": { "requestId": "..." } +} +``` + +### `GET /api/v1/gear/:slug` + +Returns the complete currently publishable catalog record, including available related specifications, aliases, media, samples, and colourways. Database primary keys and audit timestamps are omitted at every nesting level. Hidden and rumored gear return `404`. + +### `GET /api/v1/specs` + +Returns the live catalog of selectable specs. Categories such as `camera.sensor` are stable API groupings and intentionally do not mirror the website’s specs-table sections. The catalog reflects the currently deployed registry and sends `Cache-Control: no-store`. + +### `GET /api/v1/gear/:slug/specs` + +Requires a `fields` query parameter containing one to 50 comma-separated exact field IDs, categories, or a mix of both. For example: `fields=camera.sensor,lens-optics.focalLength`. + +Category selectors expand in API registry order; overlapping selectors are deduplicated. Unknown selectors return `400 invalid_request`. Applicable specs without a value are omitted. + +Each row returns durable raw data and a helpful current display string: + +```json +{ + "id": "camera.sensor.isoRange", + "raw": { "min": 100, "max": 51200 }, + "display": "ISO 100–51,200" +} +``` + +Treat `id` and `raw` as the durable contract. `display` is English current presentation output and can change. + +## Error format + +```json +{ + "error": { + "code": "invalid_api_key", + "message": "The supplied API key is invalid." + }, + "meta": { "requestId": "..." } +} +``` + +Possible v1 codes include `missing_api_key`, `invalid_api_key`, `invalid_request`, `not_found`, and `rate_limit_exceeded`. + +## Implementation boundary + +All public API-specific logic belongs in `src/server/developer-api/**`. Public routes are thin HTTP adapters and reuse the existing `server/search/service.ts` and `server/gear/service.ts` domain services. They do not call the website’s `/api/*` routes or database layer directly. diff --git a/docs/search-system.md b/docs/search-system.md index cf816b07..7042df75 100644 --- a/docs/search-system.md +++ b/docs/search-system.md @@ -46,6 +46,10 @@ Helper utilities for URLs live in `src/lib/utils/url.ts` (`buildSearchHref`, `me - `smart-action`: currently compare actions with `action: "compare"`, compare slugs/titles, and compare href - Compatibility fields `label` and `type` are still emitted for older consumers. +### Developer API + +The key-authenticated developer API has separate public endpoints at `/api/v1/search` and `/api/v1/search/suggestions`. Both reuse `searchGear` and `getSuggestions` from the search service, preserving one ranking implementation. The developer suggestion serializer omits website-specific `href` and smart compare-action data; see `docs/developer-api.md` for its public contract. + ## Database prerequisites - PostgreSQL `pg_trgm` extension is required for similarity-based ranking and fast `ILIKE` searches. diff --git a/docs/server-structure.md b/docs/server-structure.md index 08195c18..780e5908 100644 --- a/docs/server-structure.md +++ b/docs/server-structure.md @@ -78,6 +78,13 @@ This document explains how to organize server-only code under `src/server/**`. - `server/metrics/data.ts`: `getGearCount`, `getContributionCount` - `server/metrics/service.ts`: `fetchGearCount`, `fetchContributionCount` +- Developer API + - `server/developer-api/data.ts`: API-key, rate-bucket, and usage aggregate persistence. + - `server/developer-api/service.ts`: access checks, key lifecycle, rate limiting, and composition of existing search/gear domain reads. + - `server/developer-api/actions.ts`: authenticated portal/admin mutations only. + - `server/developer-api/http.ts`: shared public-route authentication and response handling. + - Developer route handlers must not call the website’s API routes or access Drizzle directly. + ## Import Rules - UI/Client Components → call Server Actions (for mutations) or import from `server/**/service.ts` (for reads). diff --git a/drizzle/0024_tiresome_lilandra.sql b/drizzle/0024_tiresome_lilandra.sql new file mode 100644 index 00000000..70d831dd --- /dev/null +++ b/drizzle/0024_tiresome_lilandra.sql @@ -0,0 +1,39 @@ +CREATE TABLE "app"."developer_api_keys" ( + "id" varchar(36) PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL, + "user_id" varchar(255) NOT NULL, + "name" varchar(100) NOT NULL, + "key_prefix" varchar(48) NOT NULL, + "key_hash" varchar(64) NOT NULL, + "last_used_at" timestamp with time zone, + "revoked_at" timestamp with time zone, + "revoked_by_user_id" varchar(255), + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "developer_api_keys_key_hash_unique" UNIQUE("key_hash") +); +--> statement-breakpoint +CREATE TABLE "app"."developer_api_rate_limit_buckets" ( + "api_key_id" varchar(36) NOT NULL, + "window_start" timestamp with time zone NOT NULL, + "request_count" integer DEFAULT 0 NOT NULL, + CONSTRAINT "developer_api_rate_limit_buckets_api_key_id_window_start_pk" PRIMARY KEY("api_key_id","window_start") +); +--> statement-breakpoint +CREATE TABLE "app"."developer_api_usage_daily" ( + "api_key_id" varchar(36) NOT NULL, + "usage_date" date NOT NULL, + "total_requests" integer DEFAULT 0 NOT NULL, + "search_requests" integer DEFAULT 0 NOT NULL, + "suggestion_requests" integer DEFAULT 0 NOT NULL, + "gear_requests" integer DEFAULT 0 NOT NULL, + CONSTRAINT "developer_api_usage_daily_api_key_id_usage_date_pk" PRIMARY KEY("api_key_id","usage_date") +); +--> statement-breakpoint +ALTER TABLE "app"."user" ADD COLUMN "developer_access_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "app"."developer_api_keys" ADD CONSTRAINT "developer_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "app"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."developer_api_keys" ADD CONSTRAINT "developer_api_keys_revoked_by_user_id_user_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "app"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."developer_api_rate_limit_buckets" ADD CONSTRAINT "developer_api_rate_limit_buckets_api_key_id_developer_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "app"."developer_api_keys"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "app"."developer_api_usage_daily" ADD CONSTRAINT "developer_api_usage_daily_api_key_id_developer_api_keys_id_fk" FOREIGN KEY ("api_key_id") REFERENCES "app"."developer_api_keys"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "developer_api_keys_user_active_idx" ON "app"."developer_api_keys" USING btree ("user_id","revoked_at");--> statement-breakpoint +CREATE INDEX "developer_api_keys_last_used_idx" ON "app"."developer_api_keys" USING btree ("last_used_at");--> statement-breakpoint +CREATE INDEX "developer_api_rate_limit_buckets_window_idx" ON "app"."developer_api_rate_limit_buckets" USING btree ("window_start");--> statement-breakpoint +CREATE INDEX "developer_api_usage_daily_date_idx" ON "app"."developer_api_usage_daily" USING btree ("usage_date"); \ No newline at end of file diff --git a/drizzle/meta/0024_snapshot.json b/drizzle/meta/0024_snapshot.json new file mode 100644 index 00000000..fde23f9d --- /dev/null +++ b/drizzle/meta/0024_snapshot.json @@ -0,0 +1,9340 @@ +{ + "id": "dc52b1a9-03b4-4fcc-aa9e-ba73046b5396", + "prevId": "82daee06-b87f-4c6d-90c6-32bd8b79cafb", + "version": "7", + "dialect": "postgresql", + "tables": { + "app.af_area_modes": { + "name": "af_area_modes", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "search_name": { + "name": "search_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "brand_id": { + "name": "brand_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "jsonb", + "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": { + "af_area_modes_brand_idx": { + "name": "af_area_modes_brand_idx", + "columns": [ + { + "expression": "brand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "af_area_modes_name_idx": { + "name": "af_area_modes_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "af_area_modes_search_name_idx": { + "name": "af_area_modes_search_name_idx", + "columns": [ + { + "expression": "search_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "af_area_modes_brand_id_brands_id_fk": { + "name": "af_area_modes_brand_id_brands_id_fk", + "tableFrom": "af_area_modes", + "tableTo": "brands", + "schemaTo": "app", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.analog_camera_specs": { + "name": "analog_camera_specs", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "camera_type": { + "name": "camera_type", + "type": "analog_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "capture_medium": { + "name": "capture_medium", + "type": "analog_medium_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "film_transport_type": { + "name": "film_transport_type", + "type": "film_transport_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "has_auto_film_advance": { + "name": "has_auto_film_advance", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_optional_motorized_drive": { + "name": "has_optional_motorized_drive", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "viewfinder_type": { + "name": "viewfinder_type", + "type": "analog_viewfinder_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shutter_type": { + "name": "shutter_type", + "type": "shutter_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shutter_speed_max": { + "name": "shutter_speed_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shutter_speed_min": { + "name": "shutter_speed_min", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flash_sync_speed": { + "name": "flash_sync_speed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_bulb_mode": { + "name": "has_bulb_mode", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_metering": { + "name": "has_metering", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "metering_modes": { + "name": "metering_modes", + "type": "metering_mode_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "exposure_modes": { + "name": "exposure_modes", + "type": "exposure_modes_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "metering_display_types": { + "name": "metering_display_types", + "type": "metering_display_type_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "has_exposure_compensation": { + "name": "has_exposure_compensation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "iso_setting_method": { + "name": "iso_setting_method", + "type": "iso_setting_method_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "iso_min": { + "name": "iso_min", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "iso_max": { + "name": "iso_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_auto_focus": { + "name": "has_auto_focus", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "focus_aid_types": { + "name": "focus_aid_types", + "type": "focus_aid_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "requires_battery_for_shutter": { + "name": "requires_battery_for_shutter", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_battery_for_metering": { + "name": "requires_battery_for_metering", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "supported_batteries": { + "name": "supported_batteries", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "has_continuous_drive": { + "name": "has_continuous_drive", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "max_continuous_fps": { + "name": "max_continuous_fps", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "has_hot_shoe": { + "name": "has_hot_shoe", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_self_timer": { + "name": "has_self_timer", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_intervalometer": { + "name": "has_intervalometer", + "type": "boolean", + "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": { + "analog_camera_specs_gear_idx": { + "name": "analog_camera_specs_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analog_camera_specs_gear_id_gear_id_fk": { + "name": "analog_camera_specs_gear_id_gear_id_fk", + "tableFrom": "analog_camera_specs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.approved_creators": { + "name": "approved_creators", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "creator_video_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "channel_url": { + "name": "channel_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "internal_notes": { + "name": "internal_notes", + "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": { + "approved_creators_name_idx": { + "name": "approved_creators_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "approved_creators_platform_active_idx": { + "name": "approved_creators_platform_active_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.audit_logs": { + "name": "audit_logs", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "action": { + "name": "action", + "type": "audit_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "gear_edit_id": { + "name": "gear_edit_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_created_idx": { + "name": "audit_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_action_idx": { + "name": "audit_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_actor_idx": { + "name": "audit_actor_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_gear_idx": { + "name": "audit_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_edit_idx": { + "name": "audit_edit_idx", + "columns": [ + { + "expression": "gear_edit_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_actor_user_id_user_id_fk": { + "name": "audit_logs_actor_user_id_user_id_fk", + "tableFrom": "audit_logs", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "audit_logs_gear_id_gear_id_fk": { + "name": "audit_logs_gear_id_gear_id_fk", + "tableFrom": "audit_logs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_gear_edit_id_gear_edits_id_fk": { + "name": "audit_logs_gear_edit_id_gear_edits_id_fk", + "tableFrom": "audit_logs", + "tableTo": "gear_edits", + "schemaTo": "app", + "columnsFrom": [ + "gear_edit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.auth_accounts": { + "name": "auth_accounts", + "schema": "app", + "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", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "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", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "auth_accounts_userId_idx": { + "name": "auth_accounts_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_accounts_user_id_user_id_fk": { + "name": "auth_accounts_user_id_user_id_fk", + "tableFrom": "auth_accounts", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.auth_sessions": { + "name": "auth_sessions", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "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": { + "auth_sessions_userId_idx": { + "name": "auth_sessions_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "auth_sessions_user_id_user_id_fk": { + "name": "auth_sessions_user_id_user_id_fk", + "tableFrom": "auth_sessions", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_sessions_token_unique": { + "name": "auth_sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.auth_verifications": { + "name": "auth_verifications", + "schema": "app", + "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", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "auth_verifications_identifier_idx": { + "name": "auth_verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.badge_awards_log": { + "name": "badge_awards_log", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "badgeKey": { + "name": "badgeKey", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "eventType": { + "name": "eventType", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "badge_award_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "awardedAt": { + "name": "awardedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "badge_awards_log_user_idx": { + "name": "badge_awards_log_user_idx", + "columns": [ + { + "expression": "userId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "badge_awards_log_awarded_idx": { + "name": "badge_awards_log_awarded_idx", + "columns": [ + { + "expression": "awardedAt", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "badge_awards_log_badge_idx": { + "name": "badge_awards_log_badge_idx", + "columns": [ + { + "expression": "badgeKey", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "badge_awards_log_userId_user_id_fk": { + "name": "badge_awards_log_userId_user_id_fk", + "tableFrom": "badge_awards_log", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.bingo_board_tiles": { + "name": "bingo_board_tiles", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "board_id": { + "name": "board_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_free_tile": { + "name": "is_free_tile", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "completed_by_user_id": { + "name": "completed_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "completed_submission_id": { + "name": "completed_submission_id", + "type": "varchar(36)", + "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": { + "bingo_board_tiles_board_position_uq": { + "name": "bingo_board_tiles_board_position_uq", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_board_tiles_board_completed_idx": { + "name": "bingo_board_tiles_board_completed_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bingo_board_tiles_board_id_bingo_boards_id_fk": { + "name": "bingo_board_tiles_board_id_bingo_boards_id_fk", + "tableFrom": "bingo_board_tiles", + "tableTo": "bingo_boards", + "schemaTo": "app", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bingo_board_tiles_completed_by_user_id_user_id_fk": { + "name": "bingo_board_tiles_completed_by_user_id_user_id_fk", + "tableFrom": "bingo_board_tiles", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "completed_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bingo_board_tiles_completed_submission_id_bingo_submissions_id_fk": { + "name": "bingo_board_tiles_completed_submission_id_bingo_submissions_id_fk", + "tableFrom": "bingo_board_tiles", + "tableTo": "bingo_submissions", + "schemaTo": "app", + "columnsFrom": [ + "completed_submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.bingo_boards": { + "name": "bingo_boards", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "status": { + "name": "status", + "type": "bingo_board_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "inactivity_duration_seconds": { + "name": "inactivity_duration_seconds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 14400 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_completed_at": { + "name": "first_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "end_reason": { + "name": "end_reason", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(255)", + "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": { + "bingo_boards_status_idx": { + "name": "bingo_boards_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_boards_created_idx": { + "name": "bingo_boards_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bingo_boards_created_by_user_id_user_id_fk": { + "name": "bingo_boards_created_by_user_id_user_id_fk", + "tableFrom": "bingo_boards", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.bingo_events": { + "name": "bingo_events", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "bingo_events_id_seq", + "schema": "app", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "bingo_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "board_id": { + "name": "board_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "board_tile_id": { + "name": "board_tile_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "submission_id": { + "name": "submission_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bingo_events_board_idx": { + "name": "bingo_events_board_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_events_board_id_idx": { + "name": "bingo_events_board_id_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_events_created_idx": { + "name": "bingo_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bingo_events_board_id_bingo_boards_id_fk": { + "name": "bingo_events_board_id_bingo_boards_id_fk", + "tableFrom": "bingo_events", + "tableTo": "bingo_boards", + "schemaTo": "app", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bingo_events_board_tile_id_bingo_board_tiles_id_fk": { + "name": "bingo_events_board_tile_id_bingo_board_tiles_id_fk", + "tableFrom": "bingo_events", + "tableTo": "bingo_board_tiles", + "schemaTo": "app", + "columnsFrom": [ + "board_tile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bingo_events_submission_id_bingo_submissions_id_fk": { + "name": "bingo_events_submission_id_bingo_submissions_id_fk", + "tableFrom": "bingo_events", + "tableTo": "bingo_submissions", + "schemaTo": "app", + "columnsFrom": [ + "submission_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "bingo_events_user_id_user_id_fk": { + "name": "bingo_events_user_id_user_id_fk", + "tableFrom": "bingo_events", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.bingo_scores": { + "name": "bingo_scores", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "board_id": { + "name": "board_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "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": { + "bingo_scores_board_user_uq": { + "name": "bingo_scores_board_user_uq", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_scores_board_points_idx": { + "name": "bingo_scores_board_points_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "points", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bingo_scores_board_id_bingo_boards_id_fk": { + "name": "bingo_scores_board_id_bingo_boards_id_fk", + "tableFrom": "bingo_scores", + "tableTo": "bingo_boards", + "schemaTo": "app", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bingo_scores_user_id_user_id_fk": { + "name": "bingo_scores_user_id_user_id_fk", + "tableFrom": "bingo_scores", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.bingo_submissions": { + "name": "bingo_submissions", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "board_id": { + "name": "board_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "board_tile_id": { + "name": "board_tile_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "discord_message_url": { + "name": "discord_message_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discord_guild_id": { + "name": "discord_guild_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "discord_channel_id": { + "name": "discord_channel_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "discord_message_id": { + "name": "discord_message_id", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "validation_passed": { + "name": "validation_passed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "validation_metadata": { + "name": "validation_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "bingo_submissions_board_idx": { + "name": "bingo_submissions_board_idx", + "columns": [ + { + "expression": "board_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_submissions_user_idx": { + "name": "bingo_submissions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_submissions_tile_idx": { + "name": "bingo_submissions_tile_idx", + "columns": [ + { + "expression": "board_tile_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bingo_submissions_created_idx": { + "name": "bingo_submissions_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bingo_submissions_board_id_bingo_boards_id_fk": { + "name": "bingo_submissions_board_id_bingo_boards_id_fk", + "tableFrom": "bingo_submissions", + "tableTo": "bingo_boards", + "schemaTo": "app", + "columnsFrom": [ + "board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bingo_submissions_board_tile_id_bingo_board_tiles_id_fk": { + "name": "bingo_submissions_board_tile_id_bingo_board_tiles_id_fk", + "tableFrom": "bingo_submissions", + "tableTo": "bingo_board_tiles", + "schemaTo": "app", + "columnsFrom": [ + "board_tile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bingo_submissions_user_id_user_id_fk": { + "name": "bingo_submissions_user_id_user_id_fk", + "tableFrom": "bingo_submissions", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.brands": { + "name": "brands", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "brands_name_unique": { + "name": "brands_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "brands_slug_unique": { + "name": "brands_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.camera_af_area_specs": { + "name": "camera_af_area_specs", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "af_area_mode_id": { + "name": "af_area_mode_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "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": { + "camera_af_area_specs_af_area_mode_idx": { + "name": "camera_af_area_specs_af_area_mode_idx", + "columns": [ + { + "expression": "af_area_mode_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "camera_af_area_specs_gear_idx": { + "name": "camera_af_area_specs_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "camera_af_area_specs_gear_id_gear_id_fk": { + "name": "camera_af_area_specs_gear_id_gear_id_fk", + "tableFrom": "camera_af_area_specs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "camera_af_area_specs_af_area_mode_id_af_area_modes_id_fk": { + "name": "camera_af_area_specs_af_area_mode_id_af_area_modes_id_fk", + "tableFrom": "camera_af_area_specs", + "tableTo": "af_area_modes", + "schemaTo": "app", + "columnsFrom": [ + "af_area_mode_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "camera_af_area_specs_gear_id_af_area_mode_id_pk": { + "name": "camera_af_area_specs_gear_id_af_area_mode_id_pk", + "columns": [ + "gear_id", + "af_area_mode_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.camera_card_slots": { + "name": "camera_card_slots", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "gear_id": { + "name": "gear_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_index": { + "name": "slot_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "supported_form_factors": { + "name": "supported_form_factors", + "type": "card_form_factor_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "supported_buses": { + "name": "supported_buses", + "type": "card_bus_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "supported_speed_classes": { + "name": "supported_speed_classes", + "type": "card_speed_class_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "uniq_camera_card_slot": { + "name": "uniq_camera_card_slot", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slot_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.camera_specs": { + "name": "camera_specs", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "sensor_format_id": { + "name": "sensor_format_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "resolution_mp": { + "name": "resolution_mp", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "sensor_stacking_type": { + "name": "sensor_stacking_type", + "type": "sensor_stacking_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "sensor_tech_type": { + "name": "sensor_tech_type", + "type": "sensor_tech_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "is_back_side_illuminated": { + "name": "is_back_side_illuminated", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "iso_min": { + "name": "iso_min", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "iso_max": { + "name": "iso_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sensor_readout_speed_ms": { + "name": "sensor_readout_speed_ms", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "max_raw_bit_depth": { + "name": "max_raw_bit_depth", + "type": "raw_bit_depth_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "has_ibis": { + "name": "has_ibis", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_electronic_vibration_reduction": { + "name": "has_electronic_vibration_reduction", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cipa_stabilization_rating_stops": { + "name": "cipa_stabilization_rating_stops", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "has_pixel_shift_shooting": { + "name": "has_pixel_shift_shooting", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_anti_aliasing_filter": { + "name": "has_anti_aliasing_filter", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "precapture_support_level": { + "name": "precapture_support_level", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "camera_type": { + "name": "camera_type", + "type": "camera_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "processor_name": { + "name": "processor_name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": false + }, + "has_weather_sealing": { + "name": "has_weather_sealing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "focus_points": { + "name": "focus_points", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "af_subject_categories": { + "name": "af_subject_categories", + "type": "camera_af_subject_categories_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "has_focus_peaking": { + "name": "has_focus_peaking", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_focus_bracketing": { + "name": "has_focus_bracketing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "shutter_speed_max": { + "name": "shutter_speed_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shutter_speed_min": { + "name": "shutter_speed_min", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_fps_raw": { + "name": "max_fps_raw", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "max_fps_jpg": { + "name": "max_fps_jpg", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "max_fps_by_shutter": { + "name": "max_fps_by_shutter", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "flash_sync_speed": { + "name": "flash_sync_speed", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_silent_shooting_available": { + "name": "has_silent_shooting_available", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "available_shutter_types": { + "name": "available_shutter_types", + "type": "shutter_types_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "internal_storage_gb": { + "name": "internal_storage_gb", + "type": "numeric(6, 1)", + "primaryKey": false, + "notNull": false + }, + "cipa_battery_shots_per_charge": { + "name": "cipa_battery_shots_per_charge", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "supported_batteries": { + "name": "supported_batteries", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "usb_power_delivery": { + "name": "usb_power_delivery", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "usb_charging": { + "name": "usb_charging", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_log_color_profile": { + "name": "has_log_color_profile", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_10_bit_video": { + "name": "has_10_bit_video", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_12_bit_video": { + "name": "has_12_bit_video", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_open_gate_video": { + "name": "has_open_gate_video", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "supports_external_recording": { + "name": "supports_external_recording", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "supports_record_to_drive": { + "name": "supports_record_to_drive", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_intervalometer": { + "name": "has_intervalometer", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_self_timer": { + "name": "has_self_timer", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_built_in_flash": { + "name": "has_built_in_flash", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_hot_shoe": { + "name": "has_hot_shoe", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_illuminated_buttons": { + "name": "has_illuminated_buttons", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_usb_file_transfer": { + "name": "has_usb_file_transfer", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "rear_display_type": { + "name": "rear_display_type", + "type": "rear_display_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "rear_display_resolution_million_dots": { + "name": "rear_display_resolution_million_dots", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "rear_display_size_inches": { + "name": "rear_display_size_inches", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "has_rear_touchscreen": { + "name": "has_rear_touchscreen", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "viewfinder_type": { + "name": "viewfinder_type", + "type": "viewfinder_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "viewfinder_magnification": { + "name": "viewfinder_magnification", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "viewfinder_resolution_million_dots": { + "name": "viewfinder_resolution_million_dots", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "has_top_display": { + "name": "has_top_display", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "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": { + "camera_specs_sensor_idx": { + "name": "camera_specs_sensor_idx", + "columns": [ + { + "expression": "sensor_format_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "camera_specs_gear_id_gear_id_fk": { + "name": "camera_specs_gear_id_gear_id_fk", + "tableFrom": "camera_specs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "camera_specs_sensor_format_id_sensor_formats_id_fk": { + "name": "camera_specs_sensor_format_id_sensor_formats_id_fk", + "tableFrom": "camera_specs", + "tableTo": "sensor_formats", + "schemaTo": "app", + "columnsFrom": [ + "sensor_format_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.camera_video_modes": { + "name": "camera_video_modes", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "resolution_key": { + "name": "resolution_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "resolution_label": { + "name": "resolution_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "resolution_horizontal": { + "name": "resolution_horizontal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "resolution_vertical": { + "name": "resolution_vertical", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fps": { + "name": "fps", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "codec_label": { + "name": "codec_label", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "bit_depth": { + "name": "bit_depth", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "crop_factor": { + "name": "crop_factor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "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": { + "camera_video_modes_gear_idx": { + "name": "camera_video_modes_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "camera_video_modes_gear_id_gear_id_fk": { + "name": "camera_video_modes_gear_id_gear_id_fk", + "tableFrom": "camera_video_modes", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.compare_pair_counts": { + "name": "compare_pair_counts", + "schema": "app", + "columns": { + "pair_key": { + "name": "pair_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "gear_a_id": { + "name": "gear_a_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "gear_b_id": { + "name": "gear_b_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pair_counts_gear_a_idx": { + "name": "pair_counts_gear_a_idx", + "columns": [ + { + "expression": "gear_a_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pair_counts_gear_b_idx": { + "name": "pair_counts_gear_b_idx", + "columns": [ + { + "expression": "gear_b_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pair_counts_count_idx": { + "name": "pair_counts_count_idx", + "columns": [ + { + "expression": "count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "compare_pair_counts_gear_a_id_gear_id_fk": { + "name": "compare_pair_counts_gear_a_id_gear_id_fk", + "tableFrom": "compare_pair_counts", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_a_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "compare_pair_counts_gear_b_id_gear_id_fk": { + "name": "compare_pair_counts_gear_b_id_gear_id_fk", + "tableFrom": "compare_pair_counts", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_b_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "compare_pair_counts_gear_a_id_gear_b_id_pk": { + "name": "compare_pair_counts_gear_a_id_gear_b_id_pk", + "columns": [ + "gear_a_id", + "gear_b_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.developer_api_keys": { + "name": "developer_api_keys", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(48)", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "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 + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "developer_api_keys_user_active_idx": { + "name": "developer_api_keys_user_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "revoked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "developer_api_keys_last_used_idx": { + "name": "developer_api_keys_last_used_idx", + "columns": [ + { + "expression": "last_used_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "developer_api_keys_user_id_user_id_fk": { + "name": "developer_api_keys_user_id_user_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "developer_api_keys_revoked_by_user_id_user_id_fk": { + "name": "developer_api_keys_revoked_by_user_id_user_id_fk", + "tableFrom": "developer_api_keys", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "revoked_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "developer_api_keys_key_hash_unique": { + "name": "developer_api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.developer_api_rate_limit_buckets": { + "name": "developer_api_rate_limit_buckets", + "schema": "app", + "columns": { + "api_key_id": { + "name": "api_key_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "developer_api_rate_limit_buckets_window_idx": { + "name": "developer_api_rate_limit_buckets_window_idx", + "columns": [ + { + "expression": "window_start", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "developer_api_rate_limit_buckets_api_key_id_developer_api_keys_id_fk": { + "name": "developer_api_rate_limit_buckets_api_key_id_developer_api_keys_id_fk", + "tableFrom": "developer_api_rate_limit_buckets", + "tableTo": "developer_api_keys", + "schemaTo": "app", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_rate_limit_buckets_api_key_id_window_start_pk": { + "name": "developer_api_rate_limit_buckets_api_key_id_window_start_pk", + "columns": [ + "api_key_id", + "window_start" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.developer_api_usage_daily": { + "name": "developer_api_usage_daily", + "schema": "app", + "columns": { + "api_key_id": { + "name": "api_key_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "usage_date": { + "name": "usage_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "search_requests": { + "name": "search_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "suggestion_requests": { + "name": "suggestion_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "gear_requests": { + "name": "gear_requests", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "developer_api_usage_daily_date_idx": { + "name": "developer_api_usage_daily_date_idx", + "columns": [ + { + "expression": "usage_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "developer_api_usage_daily_api_key_id_developer_api_keys_id_fk": { + "name": "developer_api_usage_daily_api_key_id_developer_api_keys_id_fk", + "tableFrom": "developer_api_usage_daily", + "tableTo": "developer_api_keys", + "schemaTo": "app", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "developer_api_usage_daily_api_key_id_usage_date_pk": { + "name": "developer_api_usage_daily_api_key_id_usage_date_pk", + "columns": [ + "api_key_id", + "usage_date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.exif_shutter_readings": { + "name": "exif_shutter_readings", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "tracked_camera_id": { + "name": "tracked_camera_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "capture_at": { + "name": "capture_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "primary_count_type": { + "name": "primary_count_type", + "type": "exif_primary_count_type_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "primary_count_value": { + "name": "primary_count_value", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shutter_count": { + "name": "shutter_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_shutter_count": { + "name": "total_shutter_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mechanical_shutter_count": { + "name": "mechanical_shutter_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source_tag": { + "name": "source_tag", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "mechanical_source_tag": { + "name": "mechanical_source_tag", + "type": "varchar(255)", + "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": { + "exif_shutter_readings_dedupe_uq": { + "name": "exif_shutter_readings_dedupe_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exif_shutter_readings_tracked_camera_idx": { + "name": "exif_shutter_readings_tracked_camera_idx", + "columns": [ + { + "expression": "tracked_camera_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exif_shutter_readings_camera_capture_idx": { + "name": "exif_shutter_readings_camera_capture_idx", + "columns": [ + { + "expression": "tracked_camera_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "capture_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exif_shutter_readings_tracked_camera_id_exif_tracked_cameras_id_fk": { + "name": "exif_shutter_readings_tracked_camera_id_exif_tracked_cameras_id_fk", + "tableFrom": "exif_shutter_readings", + "tableTo": "exif_tracked_cameras", + "schemaTo": "app", + "columnsFrom": [ + "tracked_camera_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.exif_tracked_cameras": { + "name": "exif_tracked_cameras", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "normalized_brand": { + "name": "normalized_brand", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "make_raw": { + "name": "make_raw", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model_raw": { + "name": "model_raw", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "serial_hash": { + "name": "serial_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_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": { + "exif_tracked_cameras_user_serial_uq": { + "name": "exif_tracked_cameras_user_serial_uq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "serial_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exif_tracked_cameras_gear_idx": { + "name": "exif_tracked_cameras_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "exif_tracked_cameras_user_idx": { + "name": "exif_tracked_cameras_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "exif_tracked_cameras_user_id_user_id_fk": { + "name": "exif_tracked_cameras_user_id_user_id_fk", + "tableFrom": "exif_tracked_cameras", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "exif_tracked_cameras_gear_id_gear_id_fk": { + "name": "exif_tracked_cameras_gear_id_gear_id_fk", + "tableFrom": "exif_tracked_cameras", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.fixed_lens_specs": { + "name": "fixed_lens_specs", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "is_prime": { + "name": "is_prime", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "focal_length_min_mm": { + "name": "focal_length_min_mm", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "focal_length_max_mm": { + "name": "focal_length_max_mm", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "image_circle_size_id": { + "name": "image_circle_size_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "max_aperture_wide": { + "name": "max_aperture_wide", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "max_aperture_tele": { + "name": "max_aperture_tele", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "min_aperture_wide": { + "name": "min_aperture_wide", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "min_aperture_tele": { + "name": "min_aperture_tele", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "has_autofocus": { + "name": "has_autofocus", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "minimum_focus_distance_mm": { + "name": "minimum_focus_distance_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "front_element_rotates": { + "name": "front_element_rotates", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "front_filter_thread_size_mm": { + "name": "front_filter_thread_size_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_lens_hood": { + "name": "has_lens_hood", + "type": "boolean", + "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": { + "fixed_lens_specs_focal_idx": { + "name": "fixed_lens_specs_focal_idx", + "columns": [ + { + "expression": "focal_length_min_mm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "focal_length_max_mm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fixed_lens_specs_gear_id_gear_id_fk": { + "name": "fixed_lens_specs_gear_id_gear_id_fk", + "tableFrom": "fixed_lens_specs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fixed_lens_specs_image_circle_size_id_sensor_formats_id_fk": { + "name": "fixed_lens_specs_image_circle_size_id_sensor_formats_id_fk", + "tableFrom": "fixed_lens_specs", + "tableTo": "sensor_formats", + "schemaTo": "app", + "columnsFrom": [ + "image_circle_size_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear": { + "name": "gear", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "slug": { + "name": "slug", + "type": "varchar(220)", + "primaryKey": false, + "notNull": true + }, + "search_name": { + "name": "search_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "model_number": { + "name": "model_number", + "type": "varchar(240)", + "primaryKey": false, + "notNull": false + }, + "gear_type": { + "name": "gear_type", + "type": "gear_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "publication_state": { + "name": "publication_state", + "type": "gear_publication_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PUBLISHED'" + }, + "brand_id": { + "name": "brand_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "mount_id": { + "name": "mount_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "announced_date": { + "name": "announced_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "announce_date_precision": { + "name": "announce_date_precision", + "type": "date_precision_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'DAY'" + }, + "release_date": { + "name": "release_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "release_date_precision": { + "name": "release_date_precision", + "type": "date_precision_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'DAY'" + }, + "msrp_now_usd_cents": { + "name": "msrp_now_usd_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "msrp_at_launch_usd_cents": { + "name": "msrp_at_launch_usd_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mpb_max_price_usd_cents": { + "name": "mpb_max_price_usd_cents", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "og_image_url": { + "name": "og_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "top_view_url": { + "name": "top_view_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rear_view_url": { + "name": "rear_view_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weight_grams": { + "name": "weight_grams", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "width_mm": { + "name": "width_mm", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "height_mm": { + "name": "height_mm", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "depth_mm": { + "name": "depth_mm", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "link_manufacturer": { + "name": "link_manufacturer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_instruction_manual": { + "name": "link_instruction_manual", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_mpb": { + "name": "link_mpb", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_bh": { + "name": "link_bh", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_amazon": { + "name": "link_amazon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "gear_search_idx": { + "name": "gear_search_idx", + "columns": [ + { + "expression": "search_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_publication_state_idx": { + "name": "gear_publication_state_idx", + "columns": [ + { + "expression": "publication_state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_type_brand_idx": { + "name": "gear_type_brand_idx", + "columns": [ + { + "expression": "gear_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "brand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_brand_mount_idx": { + "name": "gear_brand_mount_idx", + "columns": [ + { + "expression": "brand_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "mount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_brand_id_brands_id_fk": { + "name": "gear_brand_id_brands_id_fk", + "tableFrom": "gear", + "tableTo": "brands", + "schemaTo": "app", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "gear_mount_id_mounts_id_fk": { + "name": "gear_mount_id_mounts_id_fk", + "tableFrom": "gear", + "tableTo": "mounts", + "schemaTo": "app", + "columnsFrom": [ + "mount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "gear_slug_unique": { + "name": "gear_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "gear_name_unique": { + "name": "gear_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "gear_model_number_unique": { + "name": "gear_model_number_unique", + "nullsNotDistinct": false, + "columns": [ + "model_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_aliases": { + "name": "gear_aliases", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "region": { + "name": "region", + "type": "gear_region", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(240)", + "primaryKey": false, + "notNull": true + }, + "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": { + "gear_aliases_gear_idx": { + "name": "gear_aliases_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_aliases_region_idx": { + "name": "gear_aliases_region_idx", + "columns": [ + { + "expression": "region", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_aliases_gear_id_gear_id_fk": { + "name": "gear_aliases_gear_id_gear_id_fk", + "tableFrom": "gear_aliases", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_aliases_gear_id_region_pk": { + "name": "gear_aliases_gear_id_region_pk", + "columns": [ + "gear_id", + "region" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_alternatives": { + "name": "gear_alternatives", + "schema": "app", + "columns": { + "gear_a_id": { + "name": "gear_a_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "gear_b_id": { + "name": "gear_b_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "is_competitor": { + "name": "is_competitor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gear_alternatives_gear_a_idx": { + "name": "gear_alternatives_gear_a_idx", + "columns": [ + { + "expression": "gear_a_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_alternatives_gear_b_idx": { + "name": "gear_alternatives_gear_b_idx", + "columns": [ + { + "expression": "gear_b_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_alternatives_gear_a_id_gear_id_fk": { + "name": "gear_alternatives_gear_a_id_gear_id_fk", + "tableFrom": "gear_alternatives", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_a_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_alternatives_gear_b_id_gear_id_fk": { + "name": "gear_alternatives_gear_b_id_gear_id_fk", + "tableFrom": "gear_alternatives", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_b_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_alternatives_gear_a_id_gear_b_id_pk": { + "name": "gear_alternatives_gear_a_id_gear_b_id_pk", + "columns": [ + "gear_a_id", + "gear_b_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_colorways": { + "name": "gear_colorways", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(140)", + "primaryKey": false, + "notNull": true + }, + "swatch_color_a": { + "name": "swatch_color_a", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true + }, + "swatch_color_b": { + "name": "swatch_color_b", + "type": "varchar(7)", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "front_image_url": { + "name": "front_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "top_view_url": { + "name": "top_view_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rear_view_url": { + "name": "rear_view_url", + "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": { + "gear_colorways_gear_slug_uidx": { + "name": "gear_colorways_gear_slug_uidx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_colorways_gear_order_idx": { + "name": "gear_colorways_gear_order_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_colorways_gear_id_gear_id_fk": { + "name": "gear_colorways_gear_id_gear_id_fk", + "tableFrom": "gear_colorways", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_creator_videos": { + "name": "gear_creator_videos", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_url": { + "name": "normalized_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embed_url": { + "name": "embed_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "creator_video_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "external_video_id": { + "name": "external_video_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "editor_note": { + "name": "editor_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "updated_by_user_id": { + "name": "updated_by_user_id", + "type": "varchar(255)", + "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": { + "gear_creator_videos_gear_active_idx": { + "name": "gear_creator_videos_gear_active_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_creator_videos_creator_idx": { + "name": "gear_creator_videos_creator_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_creator_videos_platform_external_idx": { + "name": "gear_creator_videos_platform_external_idx", + "columns": [ + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_video_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_creator_videos_gear_platform_external_uidx": { + "name": "gear_creator_videos_gear_platform_external_uidx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "platform", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_video_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_creator_videos_gear_id_gear_id_fk": { + "name": "gear_creator_videos_gear_id_gear_id_fk", + "tableFrom": "gear_creator_videos", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_creator_videos_creator_id_approved_creators_id_fk": { + "name": "gear_creator_videos_creator_id_approved_creators_id_fk", + "tableFrom": "gear_creator_videos", + "tableTo": "approved_creators", + "schemaTo": "app", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "gear_creator_videos_created_by_user_id_user_id_fk": { + "name": "gear_creator_videos_created_by_user_id_user_id_fk", + "tableFrom": "gear_creator_videos", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "gear_creator_videos_updated_by_user_id_user_id_fk": { + "name": "gear_creator_videos_updated_by_user_id_user_id_fk", + "tableFrom": "gear_creator_videos", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "updated_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_edits": { + "name": "gear_edits", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "proposal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "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": { + "gear_edits_status_idx": { + "name": "gear_edits_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_edits_gear_idx": { + "name": "gear_edits_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_edits_created_by_idx": { + "name": "gear_edits_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_edits_gear_id_gear_id_fk": { + "name": "gear_edits_gear_id_gear_id_fk", + "tableFrom": "gear_edits", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_edits_created_by_id_user_id_fk": { + "name": "gear_edits_created_by_id_user_id_fk", + "tableFrom": "gear_edits", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_exif_aliases": { + "name": "gear_exif_aliases", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "normalized_brand": { + "name": "normalized_brand", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "make_raw": { + "name": "make_raw", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "model_raw": { + "name": "model_raw", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "make_normalized": { + "name": "make_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_normalized": { + "name": "model_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "gear_exif_aliases_gear_idx": { + "name": "gear_exif_aliases_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_exif_aliases_make_model_idx": { + "name": "gear_exif_aliases_make_model_idx", + "columns": [ + { + "expression": "make_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_exif_aliases_gear_make_model_uq": { + "name": "gear_exif_aliases_gear_make_model_uq", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "make_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "model_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_exif_aliases_gear_id_gear_id_fk": { + "name": "gear_exif_aliases_gear_id_gear_id_fk", + "tableFrom": "gear_exif_aliases", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_genres": { + "name": "gear_genres", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gear_genres_gear_idx": { + "name": "gear_genres_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_genres_genre_idx": { + "name": "gear_genres_genre_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_genres_gear_id_gear_id_fk": { + "name": "gear_genres_gear_id_gear_id_fk", + "tableFrom": "gear_genres", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_genres_genre_id_genres_id_fk": { + "name": "gear_genres_genre_id_genres_id_fk", + "tableFrom": "gear_genres", + "tableTo": "genres", + "schemaTo": "app", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_genres_gear_id_genre_id_pk": { + "name": "gear_genres_gear_id_genre_id_pk", + "columns": [ + "gear_id", + "genre_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_mounts": { + "name": "gear_mounts", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "mount_id": { + "name": "mount_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gear_mounts_gear_idx": { + "name": "gear_mounts_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_mounts_mount_idx": { + "name": "gear_mounts_mount_idx", + "columns": [ + { + "expression": "mount_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_mounts_gear_id_gear_id_fk": { + "name": "gear_mounts_gear_id_gear_id_fk", + "tableFrom": "gear_mounts", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_mounts_mount_id_mounts_id_fk": { + "name": "gear_mounts_mount_id_mounts_id_fk", + "tableFrom": "gear_mounts", + "tableTo": "mounts", + "schemaTo": "app", + "columnsFrom": [ + "mount_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_mounts_gear_id_mount_id_pk": { + "name": "gear_mounts_gear_id_mount_id_pk", + "columns": [ + "gear_id", + "mount_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_popularity_daily": { + "name": "gear_popularity_daily", + "schema": "app", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wishlist_adds": { + "name": "wishlist_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_adds": { + "name": "owner_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compare_adds": { + "name": "compare_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_submits": { + "name": "review_submits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_fetches": { + "name": "api_fetches", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gpd_gear_idx": { + "name": "gpd_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gpd_date_idx": { + "name": "gpd_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_popularity_daily_gear_id_gear_id_fk": { + "name": "gear_popularity_daily_gear_id_gear_id_fk", + "tableFrom": "gear_popularity_daily", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_popularity_daily_date_gear_id_pk": { + "name": "gear_popularity_daily_date_gear_id_pk", + "columns": [ + "date", + "gear_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_popularity_intraday": { + "name": "gear_popularity_intraday", + "schema": "app", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wishlist_adds": { + "name": "wishlist_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_adds": { + "name": "owner_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compare_adds": { + "name": "compare_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_submits": { + "name": "review_submits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_fetches": { + "name": "api_fetches", + "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": { + "gpi_gear_idx": { + "name": "gpi_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gpi_date_idx": { + "name": "gpi_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_popularity_intraday_gear_id_gear_id_fk": { + "name": "gear_popularity_intraday_gear_id_gear_id_fk", + "tableFrom": "gear_popularity_intraday", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_popularity_intraday_date_gear_id_pk": { + "name": "gear_popularity_intraday_date_gear_id_pk", + "columns": [ + "date", + "gear_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_popularity_lifetime": { + "name": "gear_popularity_lifetime", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "views_lifetime": { + "name": "views_lifetime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wishlist_lifetime_adds": { + "name": "wishlist_lifetime_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_lifetime_adds": { + "name": "owner_lifetime_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compare_lifetime_adds": { + "name": "compare_lifetime_adds", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_lifetime_submits": { + "name": "review_lifetime_submits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_fetch_lifetime": { + "name": "api_fetch_lifetime", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gpl_gear_idx": { + "name": "gpl_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_popularity_lifetime_gear_id_gear_id_fk": { + "name": "gear_popularity_lifetime_gear_id_gear_id_fk", + "tableFrom": "gear_popularity_lifetime", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_popularity_windows": { + "name": "gear_popularity_windows", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "timeframe": { + "name": "timeframe", + "type": "popularity_timeframe", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "as_of_date": { + "name": "as_of_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "views_sum": { + "name": "views_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "wishlist_adds_sum": { + "name": "wishlist_adds_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "owner_adds_sum": { + "name": "owner_adds_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "compare_adds_sum": { + "name": "compare_adds_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_submits_sum": { + "name": "review_submits_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "api_fetches_sum": { + "name": "api_fetches_sum", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gpw_timeframe_idx": { + "name": "gpw_timeframe_idx", + "columns": [ + { + "expression": "timeframe", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_popularity_windows_gear_id_gear_id_fk": { + "name": "gear_popularity_windows_gear_id_gear_id_fk", + "tableFrom": "gear_popularity_windows", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_popularity_windows_gear_id_timeframe_pk": { + "name": "gear_popularity_windows_gear_id_timeframe_pk", + "columns": [ + "gear_id", + "timeframe" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.gear_raw_samples": { + "name": "gear_raw_samples", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "raw_sample_id": { + "name": "raw_sample_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "gear_raw_samples_gear_idx": { + "name": "gear_raw_samples_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gear_raw_samples_sample_idx": { + "name": "gear_raw_samples_sample_idx", + "columns": [ + { + "expression": "raw_sample_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "gear_raw_samples_gear_id_gear_id_fk": { + "name": "gear_raw_samples_gear_id_gear_id_fk", + "tableFrom": "gear_raw_samples", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "gear_raw_samples_raw_sample_id_raw_samples_id_fk": { + "name": "gear_raw_samples_raw_sample_id_raw_samples_id_fk", + "tableFrom": "gear_raw_samples", + "tableTo": "raw_samples", + "schemaTo": "app", + "columnsFrom": [ + "raw_sample_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "gear_raw_samples_gear_id_raw_sample_id_pk": { + "name": "gear_raw_samples_gear_id_raw_sample_id_pk", + "columns": [ + "gear_id", + "raw_sample_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.genres": { + "name": "genres", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "applies_to": { + "name": "applies_to", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "genres_name_unique": { + "name": "genres_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "genres_slug_unique": { + "name": "genres_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.image_requests": { + "name": "image_requests", + "schema": "app", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_request_gear_idx": { + "name": "image_request_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_requests_user_id_user_id_fk": { + "name": "image_requests_user_id_user_id_fk", + "tableFrom": "image_requests", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_requests_gear_id_gear_id_fk": { + "name": "image_requests_gear_id_gear_id_fk", + "tableFrom": "image_requests", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "image_requests_user_id_gear_id_pk": { + "name": "image_requests_user_id_gear_id_pk", + "columns": [ + "user_id", + "gear_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.invites": { + "name": "invites", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "inviteeName": { + "name": "inviteeName", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "createdById": { + "name": "createdById", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "is_used": { + "name": "is_used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "usedByUserId": { + "name": "usedByUserId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "usedAt": { + "name": "usedAt", + "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": { + "invites_created_by_idx": { + "name": "invites_created_by_idx", + "columns": [ + { + "expression": "createdById", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_is_used_idx": { + "name": "invites_is_used_idx", + "columns": [ + { + "expression": "is_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invites_used_by_idx": { + "name": "invites_used_by_idx", + "columns": [ + { + "expression": "usedByUserId", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invites_createdById_user_id_fk": { + "name": "invites_createdById_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "createdById" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "invites_usedByUserId_user_id_fk": { + "name": "invites_usedByUserId_user_id_fk", + "tableFrom": "invites", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "usedByUserId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.lens_specs": { + "name": "lens_specs", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "is_prime": { + "name": "is_prime", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "focal_length_min_mm": { + "name": "focal_length_min_mm", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "focal_length_max_mm": { + "name": "focal_length_max_mm", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "image_circle_size_id": { + "name": "image_circle_size_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "max_aperture_wide": { + "name": "max_aperture_wide", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "max_aperture_tele": { + "name": "max_aperture_tele", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "min_aperture_wide": { + "name": "min_aperture_wide", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "min_aperture_tele": { + "name": "min_aperture_tele", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "has_stabilization": { + "name": "has_stabilization", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cipa_stabilization_rating_stops": { + "name": "cipa_stabilization_rating_stops", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "has_stabilization_switch": { + "name": "has_stabilization_switch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_autofocus": { + "name": "has_autofocus", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_macro": { + "name": "is_macro", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "magnification": { + "name": "magnification", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": false + }, + "minimum_focus_distance_mm": { + "name": "minimum_focus_distance_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_focus_ring": { + "name": "has_focus_ring", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "focus_motor_type": { + "name": "focus_motor_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "focus_throw_degrees": { + "name": "focus_throw_degrees", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_af_mf_switch": { + "name": "has_af_mf_switch", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_focus_limiter": { + "name": "has_focus_limiter", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_focus_recall_button": { + "name": "has_focus_recall_button", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "number_elements": { + "name": "number_elements", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_element_groups": { + "name": "number_element_groups", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_diffractive_optics": { + "name": "has_diffractive_optics", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "lens_zoom_type": { + "name": "lens_zoom_type", + "type": "lens_zoom_types_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "number_diaphragm_blades": { + "name": "number_diaphragm_blades", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_rounded_diaphragm_blades": { + "name": "has_rounded_diaphragm_blades", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_internal_zoom": { + "name": "has_internal_zoom", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_internal_focus": { + "name": "has_internal_focus", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "front_element_rotates": { + "name": "front_element_rotates", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "mount_material": { + "name": "mount_material", + "type": "mount_material_enum", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "has_weather_sealing": { + "name": "has_weather_sealing", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_aperture_ring": { + "name": "has_aperture_ring", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "number_custom_control_rings": { + "name": "number_custom_control_rings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "number_function_buttons": { + "name": "number_function_buttons", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "accepts_filter_types": { + "name": "accepts_filter_types", + "type": "lens_filter_types_enum[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "front_filter_thread_size_mm": { + "name": "front_filter_thread_size_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rear_filter_thread_size_mm": { + "name": "rear_filter_thread_size_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "drop_in_filter_size_mm": { + "name": "drop_in_filter_size_mm", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_built_in_teleconverter": { + "name": "has_built_in_teleconverter", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_lens_hood": { + "name": "has_lens_hood", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "has_tripod_collar": { + "name": "has_tripod_collar", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "is_tilt_shift": { + "name": "is_tilt_shift", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "tilt_degrees": { + "name": "tilt_degrees", + "type": "numeric(4, 1)", + "primaryKey": false, + "notNull": false + }, + "shift_mm": { + "name": "shift_mm", + "type": "numeric(5, 1)", + "primaryKey": false, + "notNull": false + }, + "extra": { + "name": "extra", + "type": "jsonb", + "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": { + "lens_specs_focal_idx": { + "name": "lens_specs_focal_idx", + "columns": [ + { + "expression": "focal_length_min_mm", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "focal_length_max_mm", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lens_specs_gear_id_gear_id_fk": { + "name": "lens_specs_gear_id_gear_id_fk", + "tableFrom": "lens_specs", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lens_specs_image_circle_size_id_sensor_formats_id_fk": { + "name": "lens_specs_image_circle_size_id_sensor_formats_id_fk", + "tableFrom": "lens_specs", + "tableTo": "sensor_formats", + "schemaTo": "app", + "columnsFrom": [ + "image_circle_size_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.mounts": { + "name": "mounts", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "value": { + "name": "value", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "short_name": { + "name": "short_name", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "brand_id": { + "name": "brand_id", + "type": "varchar(36)", + "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": {}, + "foreignKeys": { + "mounts_brand_id_brands_id_fk": { + "name": "mounts_brand_id_brands_id_fk", + "tableFrom": "mounts", + "tableTo": "brands", + "schemaTo": "app", + "columnsFrom": [ + "brand_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mounts_value_unique": { + "name": "mounts_value_unique", + "nullsNotDistinct": false, + "columns": [ + "value" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.notifications": { + "name": "notifications", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "link_url": { + "name": "link_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "source_id": { + "name": "source_id", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_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()" + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_unread_idx": { + "name": "notifications_user_unread_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notifications_user_archived_idx": { + "name": "notifications_user_archived_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_user_id_fk": { + "name": "notifications_user_id_user_id_fk", + "tableFrom": "notifications", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.ownerships": { + "name": "ownerships", + "schema": "app", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "colorway_id": { + "name": "colorway_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ownership_gear_idx": { + "name": "ownership_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ownership_colorway_idx": { + "name": "ownership_colorway_idx", + "columns": [ + { + "expression": "colorway_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ownerships_user_id_user_id_fk": { + "name": "ownerships_user_id_user_id_fk", + "tableFrom": "ownerships", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ownerships_gear_id_gear_id_fk": { + "name": "ownerships_gear_id_gear_id_fk", + "tableFrom": "ownerships", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ownerships_colorway_id_gear_colorways_id_fk": { + "name": "ownerships_colorway_id_gear_colorways_id_fk", + "tableFrom": "ownerships", + "tableTo": "gear_colorways", + "schemaTo": "app", + "columnsFrom": [ + "colorway_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "ownerships_user_id_gear_id_pk": { + "name": "ownerships_user_id_gear_id_pk", + "columns": [ + "user_id", + "gear_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.passkeys": { + "name": "passkeys", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_type": { + "name": "device_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkeys_user_idx": { + "name": "passkeys_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkeys_user_id_user_id_fk": { + "name": "passkeys_user_id_user_id_fk", + "tableFrom": "passkeys", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.popularity_events": { + "name": "popularity_events", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "visitor_id": { + "name": "visitor_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "popularity_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pop_events_gear_idx": { + "name": "pop_events_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pop_events_gear_type_idx": { + "name": "pop_events_gear_type_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pop_events_created_idx": { + "name": "pop_events_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pop_events_visitor_idx": { + "name": "pop_events_visitor_idx", + "columns": [ + { + "expression": "visitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pop_events_gear_visitor_created_idx": { + "name": "pop_events_gear_visitor_created_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "popularity_events_gear_id_gear_id_fk": { + "name": "popularity_events_gear_id_gear_id_fk", + "tableFrom": "popularity_events", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "popularity_events_user_id_user_id_fk": { + "name": "popularity_events_user_id_user_id_fk", + "tableFrom": "popularity_events", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.raw_samples": { + "name": "raw_samples", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_filename": { + "name": "original_filename", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "uploaded_by_user_id": { + "name": "uploaded_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_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": { + "raw_samples_file_url_idx": { + "name": "raw_samples_file_url_idx", + "columns": [ + { + "expression": "file_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "raw_samples_user_idx": { + "name": "raw_samples_user_idx", + "columns": [ + { + "expression": "uploaded_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.recommendation_charts": { + "name": "recommendation_charts", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "brand": { + "name": "brand", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(300)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(800)", + "primaryKey": false, + "notNull": false + }, + "updated_date": { + "name": "updated_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "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": { + "rec_brand_slug": { + "name": "rec_brand_slug", + "columns": [ + { + "expression": "brand", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.recommendation_items": { + "name": "recommendation_items", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "chart_id": { + "name": "chart_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "rating": { + "name": "rating", + "type": "rec_rating", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "group_override": { + "name": "group_override", + "type": "rec_group", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "custom_column": { + "name": "custom_column", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "price_min_override": { + "name": "price_min_override", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price_max_override": { + "name": "price_max_override", + "type": "integer", + "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": { + "rec_items_chart_idx": { + "name": "rec_items_chart_idx", + "columns": [ + { + "expression": "chart_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_items_gear_idx": { + "name": "rec_items_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendation_items_chart_id_recommendation_charts_id_fk": { + "name": "recommendation_items_chart_id_recommendation_charts_id_fk", + "tableFrom": "recommendation_items", + "tableTo": "recommendation_charts", + "schemaTo": "app", + "columnsFrom": [ + "chart_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recommendation_items_gear_id_gear_id_fk": { + "name": "recommendation_items_gear_id_gear_id_fk", + "tableFrom": "recommendation_items", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.review_flags": { + "name": "review_flags", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "review_id": { + "name": "review_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "reporter_user_id": { + "name": "reporter_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_flag_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'OPEN'" + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_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": { + "review_flags_status_idx": { + "name": "review_flags_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_flags_review_status_idx": { + "name": "review_flags_review_status_idx", + "columns": [ + { + "expression": "review_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "review_flags_reporter_status_idx": { + "name": "review_flags_reporter_status_idx", + "columns": [ + { + "expression": "reporter_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_flags_review_id_reviews_id_fk": { + "name": "review_flags_review_id_reviews_id_fk", + "tableFrom": "review_flags", + "tableTo": "reviews", + "schemaTo": "app", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_flags_reporter_user_id_user_id_fk": { + "name": "review_flags_reporter_user_id_user_id_fk", + "tableFrom": "review_flags", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "reporter_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "review_flags_resolved_by_user_id_user_id_fk": { + "name": "review_flags_resolved_by_user_id_user_id_fk", + "tableFrom": "review_flags", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "resolved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.review_summaries": { + "name": "review_summaries", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "summary_text": { + "name": "summary_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "review_summaries_updated_idx": { + "name": "review_summaries_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "review_summaries_gear_id_gear_id_fk": { + "name": "review_summaries_gear_id_gear_id_fk", + "tableFrom": "review_summaries", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.reviews": { + "name": "reviews", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_by_id": { + "name": "created_by_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "review_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "recommend": { + "name": "recommend", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "reviews_status_idx": { + "name": "reviews_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_gear_idx": { + "name": "reviews_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_created_by_idx": { + "name": "reviews_created_by_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reviews_created_by_created_at_idx": { + "name": "reviews_created_by_created_at_idx", + "columns": [ + { + "expression": "created_by_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviews_gear_id_gear_id_fk": { + "name": "reviews_gear_id_gear_id_fk", + "tableFrom": "reviews", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviews_created_by_id_user_id_fk": { + "name": "reviews_created_by_id_user_id_fk", + "tableFrom": "reviews", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.rollup_runs": { + "name": "rollup_runs", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "as_of_date": { + "name": "as_of_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "corrected_date": { + "name": "corrected_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "daily_rows": { + "name": "daily_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "late_arrivals": { + "name": "late_arrivals", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "windows_rows": { + "name": "windows_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "lifetime_total_rows": { + "name": "lifetime_total_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "rollup_runs_created_idx": { + "name": "rollup_runs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.sensor_formats": { + "name": "sensor_formats", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "name": { + "name": "name", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "crop_factor": { + "name": "crop_factor", + "type": "numeric(4, 2)", + "primaryKey": false, + "notNull": true + }, + "width_mm": { + "name": "width_mm", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "height_mm": { + "name": "height_mm", + "type": "numeric(6, 2)", + "primaryKey": false, + "notNull": false + }, + "area_mm_2": { + "name": "area_mm_2", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "varchar(500)", + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sensor_formats_name_unique": { + "name": "sensor_formats_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "sensor_formats_slug_unique": { + "name": "sensor_formats_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.shared_lists": { + "name": "shared_lists", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "list_id": { + "name": "list_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(180)", + "primaryKey": false, + "notNull": true + }, + "public_id": { + "name": "public_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "unpublished_at": { + "name": "unpublished_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": { + "shared_lists_list_uq": { + "name": "shared_lists_list_uq", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_lists_public_id_uq": { + "name": "shared_lists_public_id_uq", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shared_lists_slug_idx": { + "name": "shared_lists_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shared_lists_list_id_user_lists_id_fk": { + "name": "shared_lists_list_id_user_lists_id_fk", + "tableFrom": "shared_lists", + "tableTo": "user_lists", + "schemaTo": "app", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.staff_verdicts": { + "name": "staff_verdicts", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pros": { + "name": "pros", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cons": { + "name": "cons", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "who_for": { + "name": "who_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "not_for": { + "name": "not_for", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "alternatives": { + "name": "alternatives", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "author_user_id": { + "name": "author_user_id", + "type": "varchar(255)", + "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": { + "staff_verdicts_author_idx": { + "name": "staff_verdicts_author_idx", + "columns": [ + { + "expression": "author_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "staff_verdicts_gear_id_gear_id_fk": { + "name": "staff_verdicts_gear_id_gear_id_fk", + "tableFrom": "staff_verdicts", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "staff_verdicts_author_user_id_user_id_fk": { + "name": "staff_verdicts_author_user_id_user_id_fk", + "tableFrom": "staff_verdicts", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "author_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.use_case_ratings": { + "name": "use_case_ratings", + "schema": "app", + "columns": { + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "genre_id": { + "name": "genre_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "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": { + "ucr_gear_idx": { + "name": "ucr_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ucr_genre_idx": { + "name": "ucr_genre_idx", + "columns": [ + { + "expression": "genre_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "use_case_ratings_gear_id_gear_id_fk": { + "name": "use_case_ratings_gear_id_gear_id_fk", + "tableFrom": "use_case_ratings", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "use_case_ratings_genre_id_genres_id_fk": { + "name": "use_case_ratings_genre_id_genres_id_fk", + "tableFrom": "use_case_ratings", + "tableTo": "genres", + "schemaTo": "app", + "columnsFrom": [ + "genre_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "use_case_ratings_gear_id_genre_id_pk": { + "name": "use_case_ratings_gear_id_genre_id_pk", + "columns": [ + "gear_id", + "genre_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.user_badges": { + "name": "user_badges", + "schema": "app", + "columns": { + "userId": { + "name": "userId", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "badgeKey": { + "name": "badgeKey", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "awardedAt": { + "name": "awardedAt", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "source": { + "name": "source", + "type": "badge_award_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_override": { + "name": "sort_override", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_badges_userId_user_id_fk": { + "name": "user_badges_userId_user_id_fk", + "tableFrom": "user_badges", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_badges_userId_badgeKey_pk": { + "name": "user_badges_userId_badgeKey_pk", + "columns": [ + "userId", + "badgeKey" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.user_list_items": { + "name": "user_list_items", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "list_id": { + "name": "list_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "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": { + "user_list_items_list_gear_uq": { + "name": "user_list_items_list_gear_uq", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_list_items_list_position_idx": { + "name": "user_list_items_list_position_idx", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_list_items_gear_idx": { + "name": "user_list_items_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_list_items_list_id_user_lists_id_fk": { + "name": "user_list_items_list_id_user_lists_id_fk", + "tableFrom": "user_list_items", + "tableTo": "user_lists", + "schemaTo": "app", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_list_items_gear_id_gear_id_fk": { + "name": "user_list_items_gear_id_gear_id_fk", + "tableFrom": "user_list_items", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.user_lists": { + "name": "user_lists", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(36)", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()::text" + }, + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(140)", + "primaryKey": false, + "notNull": true + }, + "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, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_lists_user_idx": { + "name": "user_lists_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_lists_user_created_idx": { + "name": "user_lists_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_lists_user_id_user_id_fk": { + "name": "user_lists_user_id_user_id_fk", + "tableFrom": "user_lists", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.user": { + "name": "user", + "schema": "app", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USER'" + }, + "member_number": { + "name": "member_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "identity": { + "type": "byDefault", + "name": "user_member_number_seq", + "schema": "app", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "invite_id": { + "name": "invite_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "developer_access_enabled": { + "name": "developer_access_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_handle_unique": { + "name": "user_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + }, + "user_member_number_unique": { + "name": "user_member_number_unique", + "nullsNotDistinct": false, + "columns": [ + "member_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "app.wishlists": { + "name": "wishlists", + "schema": "app", + "columns": { + "user_id": { + "name": "user_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "gear_id": { + "name": "gear_id", + "type": "varchar(36)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "wishlist_gear_idx": { + "name": "wishlist_gear_idx", + "columns": [ + { + "expression": "gear_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wishlists_user_id_user_id_fk": { + "name": "wishlists_user_id_user_id_fk", + "tableFrom": "wishlists", + "tableTo": "user", + "schemaTo": "app", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlists_gear_id_gear_id_fk": { + "name": "wishlists_gear_id_gear_id_fk", + "tableFrom": "wishlists", + "tableTo": "gear", + "schemaTo": "app", + "columnsFrom": [ + "gear_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "wishlists_user_id_gear_id_pk": { + "name": "wishlists_user_id_gear_id_pk", + "columns": [ + "user_id", + "gear_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.camera_af_subject_categories_enum": { + "name": "camera_af_subject_categories_enum", + "schema": "public", + "values": [ + "people", + "animals", + "vehicles", + "birds", + "aircraft" + ] + }, + "public.analog_medium_enum": { + "name": "analog_medium_enum", + "schema": "public", + "values": [ + "35mm", + "half-frame", + "panoramic-35mm", + "aps", + "110", + "126-instamatic", + "828", + "120", + "220", + "127", + "620", + "large-format-4x5", + "large-format-5x7", + "large-format-8x10", + "ultra-large-format", + "sheet-film", + "metal-plate", + "glass-plate", + "experimental-other" + ] + }, + "public.analog_types_enum": { + "name": "analog_types_enum", + "schema": "public", + "values": [ + "single-lens-reflex-slr", + "rangefinder", + "twin-lens-reflex-tlr", + "point-and-shoot", + "large-format", + "instant-film", + "disposable", + "toy", + "stereo", + "panoramic", + "folding", + "box", + "pinhole", + "press" + ] + }, + "public.analog_viewfinder_types_enum": { + "name": "analog_viewfinder_types_enum", + "schema": "public", + "values": [ + "none", + "pentaprism", + "pentamirror", + "waist-level", + "sports-finder", + "rangefinder-style", + "ground-glass", + "other" + ] + }, + "public.audit_action": { + "name": "audit_action", + "schema": "public", + "values": [ + "GEAR_CREATE", + "GEAR_RENAME", + "GEAR_DELETE", + "GEAR_IMAGE_UPLOAD", + "GEAR_IMAGE_REPLACE", + "GEAR_IMAGE_REMOVE", + "GEAR_TOP_VIEW_UPLOAD", + "GEAR_TOP_VIEW_REPLACE", + "GEAR_TOP_VIEW_REMOVE", + "GEAR_REAR_VIEW_UPLOAD", + "GEAR_REAR_VIEW_REPLACE", + "GEAR_REAR_VIEW_REMOVE", + "GEAR_COLORWAY_CREATE", + "GEAR_COLORWAY_UPDATE", + "GEAR_COLORWAY_REORDER", + "GEAR_COLORWAY_DELETE", + "GEAR_COLORWAY_RESET", + "GEAR_COLORWAY_IMAGE_UPLOAD", + "GEAR_COLORWAY_IMAGE_REPLACE", + "GEAR_COLORWAY_IMAGE_REMOVE", + "GEAR_EDIT_PROPOSE", + "GEAR_EDIT_APPROVE", + "GEAR_EDIT_REJECT", + "GEAR_EDIT_MERGE", + "REVIEW_APPROVE", + "REVIEW_REJECT" + ] + }, + "public.badge_award_source": { + "name": "badge_award_source", + "schema": "public", + "values": [ + "auto", + "manual" + ] + }, + "public.bingo_board_status": { + "name": "bingo_board_status", + "schema": "public", + "values": [ + "ACTIVE", + "COMPLETED", + "EXPIRED", + "ARCHIVED" + ] + }, + "public.bingo_event_type": { + "name": "bingo_event_type", + "schema": "public", + "values": [ + "tile_completed", + "submission_created", + "score_updated", + "board_completed", + "board_expired", + "board_created", + "inactivity_timer_started", + "inactivity_timer_extended" + ] + }, + "public.camera_type_enum": { + "name": "camera_type_enum", + "schema": "public", + "values": [ + "dslr", + "mirrorless", + "slr", + "action", + "cinema" + ] + }, + "public.card_bus_enum": { + "name": "card_bus_enum", + "schema": "public", + "values": [ + "sd_default", + "uhs_i", + "uhs_ii", + "uhs_iii", + "sd_express", + "cfexpress_pcie_gen3x1", + "cfexpress_pcie_gen3x2", + "cfexpress_pcie_gen4x1", + "cfexpress_pcie_gen4x2", + "xqd_1_0", + "xqd_2_0", + "cfast_sata_ii", + "cfast_sata_iii", + "cf_udma4", + "cf_udma5", + "cf_udma6", + "cf_udma7", + "sxs_pcie_gen1", + "sxs_pcie_gen2", + "p2_pci" + ] + }, + "public.card_form_factor_enum": { + "name": "card_form_factor_enum", + "schema": "public", + "values": [ + "sd", + "micro_sd", + "cfexpress_type_a", + "cfexpress_type_b", + "cfexpress_type_c", + "xqd", + "cfast", + "compactflash_type_i", + "compactflash_type_ii", + "memory_stick_pro_duo", + "memory_stick_pro_hg_duo", + "xd_picture_card", + "smartmedia", + "sxs", + "p2" + ] + }, + "public.card_speed_class_enum": { + "name": "card_speed_class_enum", + "schema": "public", + "values": [ + "c2", + "c4", + "c6", + "c10", + "u1", + "u3", + "v6", + "v10", + "v30", + "v60", + "v90", + "vpg_20", + "vpg_65", + "vpg_130" + ] + }, + "public.creator_video_platform": { + "name": "creator_video_platform", + "schema": "public", + "values": [ + "YOUTUBE" + ] + }, + "public.date_precision_enum": { + "name": "date_precision_enum", + "schema": "public", + "values": [ + "YEAR", + "MONTH", + "DAY" + ] + }, + "public.exif_primary_count_type_enum": { + "name": "exif_primary_count_type_enum", + "schema": "public", + "values": [ + "total", + "mechanical", + "generic" + ] + }, + "public.metering_mode_enum": { + "name": "metering_mode_enum", + "schema": "public", + "values": [ + "average", + "center-weighted", + "spot", + "other" + ] + }, + "public.exposure_modes_enum": { + "name": "exposure_modes_enum", + "schema": "public", + "values": [ + "manual", + "aperture-priority", + "shutter-priority", + "program", + "auto", + "other" + ] + }, + "public.film_transport_enum": { + "name": "film_transport_enum", + "schema": "public", + "values": [ + "not-applicable", + "manual-lever", + "manual-knob", + "manual-other", + "motorized", + "other" + ] + }, + "public.focus_aid_enum": { + "name": "focus_aid_enum", + "schema": "public", + "values": [ + "none", + "split-prism", + "microprism", + "rangefinder-patch", + "electronic-confirm", + "electronic-directional", + "af-point" + ] + }, + "public.gear_publication_state": { + "name": "gear_publication_state", + "schema": "public", + "values": [ + "PUBLISHED", + "RUMORED", + "HIDDEN" + ] + }, + "public.gear_region": { + "name": "gear_region", + "schema": "public", + "values": [ + "GLOBAL", + "EU", + "JP" + ] + }, + "public.gear_type": { + "name": "gear_type", + "schema": "public", + "values": [ + "CAMERA", + "ANALOG_CAMERA", + "LENS" + ] + }, + "public.iso_setting_method_enum": { + "name": "iso_setting_method_enum", + "schema": "public", + "values": [ + "manual", + "dx-only", + "dx-with-override", + "fixed", + "other" + ] + }, + "public.lens_filter_types_enum": { + "name": "lens_filter_types_enum", + "schema": "public", + "values": [ + "none", + "front-screw-on", + "rear-screw-on", + "rear-bayonet", + "rear-gel-slot", + "rear-drop-in", + "internal-rotary" + ] + }, + "public.lens_zoom_types_enum": { + "name": "lens_zoom_types_enum", + "schema": "public", + "values": [ + "two-ring", + "push-pull", + "power-zoom", + "zoom-by-wire", + "collar-zoom", + "other" + ] + }, + "public.metering_display_type_enum": { + "name": "metering_display_type_enum", + "schema": "public", + "values": [ + "needle-middle", + "needle-match", + "led-indicators", + "led-scale", + "lcd-viewfinder", + "lcd-top-panel", + "none", + "other" + ] + }, + "public.mount_material_enum": { + "name": "mount_material_enum", + "schema": "public", + "values": [ + "metal", + "plastic" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "gear_spec_approved", + "badge_awarded", + "prompt_handle_setup" + ] + }, + "public.popularity_event_type": { + "name": "popularity_event_type", + "schema": "public", + "values": [ + "view", + "wishlist_add", + "owner_add", + "compare_add", + "review_submit", + "api_fetch" + ] + }, + "public.popularity_timeframe": { + "name": "popularity_timeframe", + "schema": "public", + "values": [ + "7d", + "30d" + ] + }, + "public.proposal_status": { + "name": "proposal_status", + "schema": "public", + "values": [ + "PENDING", + "APPROVED", + "REJECTED", + "MERGED" + ] + }, + "public.raw_bit_depth_enum": { + "name": "raw_bit_depth_enum", + "schema": "public", + "values": [ + "10", + "12", + "14", + "16" + ] + }, + "public.rear_display_types_enum": { + "name": "rear_display_types_enum", + "schema": "public", + "values": [ + "none", + "fixed", + "single_axis_tilt", + "dual_axis_tilt", + "fully_articulated", + "four_axis_tilt_flip", + "other" + ] + }, + "public.rec_group": { + "name": "rec_group", + "schema": "public", + "values": [ + "prime", + "zoom" + ] + }, + "public.rec_rating": { + "name": "rec_rating", + "schema": "public", + "values": [ + "best value", + "best performance", + "situational", + "balanced" + ] + }, + "public.review_flag_status": { + "name": "review_flag_status", + "schema": "public", + "values": [ + "OPEN", + "RESOLVED_KEEP", + "RESOLVED_REJECTED", + "RESOLVED_DELETED" + ] + }, + "public.review_status": { + "name": "review_status", + "schema": "public", + "values": [ + "PENDING", + "APPROVED", + "REJECTED" + ] + }, + "public.sensor_stacking_types_enum": { + "name": "sensor_stacking_types_enum", + "schema": "public", + "values": [ + "unstacked", + "partially-stacked", + "fully-stacked" + ] + }, + "public.sensor_tech_types_enum": { + "name": "sensor_tech_types_enum", + "schema": "public", + "values": [ + "cmos", + "ccd" + ] + }, + "public.shutter_type_enum": { + "name": "shutter_type_enum", + "schema": "public", + "values": [ + "focal-plane-cloth", + "focal-plane-metal", + "focal-plane-electronic", + "leaf", + "rotary", + "none", + "other" + ] + }, + "public.shutter_types_enum": { + "name": "shutter_types_enum", + "schema": "public", + "values": [ + "mechanical", + "efc", + "electronic" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "USER", + "MODERATOR", + "EDITOR", + "ADMIN", + "SUPERADMIN" + ] + }, + "public.viewfinder_types_enum": { + "name": "viewfinder_types_enum", + "schema": "public", + "values": [ + "none", + "optical", + "electronic" + ] + } + }, + "schemas": { + "app": "app" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d6542fe0..6d43aed8 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1783011090132, "tag": "0023_daily_zzzax", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1784051102838, + "tag": "0024_tiresome_lilandra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/messages/de.json b/messages/de.json index ba71de75..d97a8288 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6,6 +6,7 @@ "account": "Konto", "logOut": "Abmelden", "adminPanel": "Admin-Bereich", + "developerPortal": "Entwicklerportal", "privacy": "Datenschutz", "termsOfService": "Nutzungsbedingungen", "language": "Sprache", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Willkommen, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Verwende die API von deinem Server oder einem anderen vertrauenswürdigen Backend aus. Browser-Anfragen werden nicht unterstützt, da keine CORS-Header gesendet werden; veröffentliche niemals einen Live-API-Schlüssel in clientseitigem Code.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Beispielanfrage", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "Echte Specs, echte Reviews, richtig schnell. Sharply ist die neueste und umfassendste Plattform für Fotografie-Ausrüstungsdatenbanken und Reviews mit Expertenbewertungen, echten Spezifikationen und direkten Vergleichen in einer modernen, minimalistischen Oberfläche.", "signInTitle": "Anmelden", diff --git a/messages/en.json b/messages/en.json index fde75074..14e74811 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6,6 +6,7 @@ "account": "Account", "logOut": "Log out", "adminPanel": "Admin Panel", + "developerPortal": "Developer Portal", "privacy": "Privacy", "termsOfService": "Terms of Service", "language": "Language", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Welcome, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Use the API from your server or another trusted backend. Browser requests are not supported because CORS headers are not sent; never expose a live API key in client-side code.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Example request", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "Real specs, real reviews, real fast. Sharply is the newest and most comprehensive photography gear database and review platform featuring expert reviews, real specs, and side-by-side comparisons in a modern, minimalist interface.", "signInTitle": "Log In", diff --git a/messages/es.json b/messages/es.json index 97b47b93..d61f5732 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6,6 +6,7 @@ "account": "Cuenta", "logOut": "Cerrar sesión", "adminPanel": "Panel de administración", + "developerPortal": "Portal para desarrolladores", "privacy": "Privacidad", "termsOfService": "Términos del servicio", "language": "Idioma", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Bienvenido/a, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Usa la API desde tu servidor u otro backend de confianza. Las solicitudes desde el navegador no son compatibles porque no se envían encabezados CORS; nunca expongas una clave de API activa en código del cliente.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Solicitud de ejemplo", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "Especificaciones reales, reseñas reales, muy rápido. Sharply es la plataforma más nueva y completa de base de datos y reseñas de equipo fotográfico, con reseñas de expertos, especificaciones reales y comparaciones lado a lado en una interfaz moderna y minimalista.", "signInTitle": "Iniciar sesión", diff --git a/messages/fr.json b/messages/fr.json index b687414f..a1ecbad3 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6,6 +6,7 @@ "account": "Compte", "logOut": "Se déconnecter", "adminPanel": "Panneau d’administration", + "developerPortal": "Portail développeur", "privacy": "Confidentialité", "termsOfService": "Conditions d’utilisation", "language": "Langue", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Bienvenue, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Utilisez l’API depuis votre serveur ou un autre backend de confiance. Les requêtes depuis le navigateur ne sont pas prises en charge, car aucun en-tête CORS n’est envoyé ; n’exposez jamais une clé API active dans du code côté client.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Exemple de requête", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "De vraies specs, de vrais tests, très rapidement. Sharply est la plateforme de base de données et de tests de matériel photo la plus récente et la plus complète, avec des tests d’experts, de vraies spécifications et des comparaisons côte à côte dans une interface moderne et minimaliste.", "signInTitle": "Connexion", diff --git a/messages/it.json b/messages/it.json index c69c6199..482cd43f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6,6 +6,7 @@ "account": "Account", "logOut": "Esci", "adminPanel": "Pannello di amministrazione", + "developerPortal": "Portale sviluppatori", "privacy": "Privacy", "termsOfService": "Termini di servizio", "language": "Lingua", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Benvenuto/a, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Usa l’API dal tuo server o da un altro backend affidabile. Le richieste dal browser non sono supportate perché non vengono inviati header CORS; non esporre mai una chiave API attiva nel codice lato client.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Esempio di richiesta", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "Specifiche reali, recensioni reali, davvero veloci. Sharply è la piattaforma più nuova e completa per database e recensioni di attrezzatura fotografica, con recensioni di esperti, specifiche reali e confronti affiancati in un’interfaccia moderna e minimalista.", "signInTitle": "Accedi", diff --git a/messages/ja.json b/messages/ja.json index 07f05d6d..58188a61 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -6,6 +6,7 @@ "account": "アカウント", "logOut": "ログアウト", "adminPanel": "管理パネル", + "developerPortal": "開発者ポータル", "privacy": "プライバシー", "termsOfService": "利用規約", "language": "言語", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "ようこそ、{name}さん", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "API はサーバーまたは信頼できるバックエンドから利用してください。CORS ヘッダーを送信しないため、ブラウザからのリクエストはサポートしていません。ライブ API キーをクライアント側のコードに公開しないでください。", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "リクエスト例", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "本物のスペック、本物のレビュー、すばやく。Sharplyは、専門家のレビュー、実際のスペック、並べて比較できる機能を備えた、最新かつ最も包括的な写真機材データベース兼レビュープラットフォームです。モダンでミニマルなインターフェースで提供します。", "signInTitle": "ログイン", diff --git a/messages/ms.json b/messages/ms.json index 4bda250e..b3a6ab67 100644 --- a/messages/ms.json +++ b/messages/ms.json @@ -6,6 +6,7 @@ "account": "Akaun", "logOut": "Log keluar", "adminPanel": "Panel Pentadbir", + "developerPortal": "Portal Pembangun", "privacy": "Privasi", "termsOfService": "Syarat Perkhidmatan", "language": "Bahasa", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "Selamat datang, {name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "Gunakan API daripada pelayan anda atau backend lain yang dipercayai. Permintaan pelayar tidak disokong kerana pengepala CORS tidak dihantar; jangan sekali-kali dedahkan kunci API aktif dalam kod sisi klien.", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "Contoh permintaan", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "Spesifikasi sebenar, ulasan sebenar, sangat pantas. Sharply ialah pangkalan data gear fotografi dan platform semakan terbaharu dan paling komprehensif yang menampilkan ulasan pakar, spesifikasi sebenar dan perbandingan sebelah menyebelah dalam antara muka moden dan minimalis.", "signInTitle": "Log Masuk", diff --git a/messages/zh.json b/messages/zh.json index d059d53a..be2fb049 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -6,6 +6,7 @@ "account": "账户", "logOut": "退出登录", "adminPanel": "管理面板", + "developerPortal": "开发者门户", "privacy": "隐私", "termsOfService": "服务条款", "language": "语言", @@ -1852,6 +1853,94 @@ } } }, + "developerApi": { + "portal": { + "eyebrow": "Developer API", + "accessTitle": "Developer access is not enabled", + "accessDescription": "An administrator must enable developer API access for your account before you can create keys.", + "title": "API keys", + "welcome": "欢迎,{name}", + "description": "Create and manage credentials for the Sharply developer API.", + "rateLimit": "Free beta · {count} requests per minute per key", + "secretTitle": "Copy your new API key now", + "secretDescription": "For security, this secret will not be shown again.", + "copied": "Copied", + "copy": "Copy", + "dismiss": "Dismiss", + "keysTitle": "Your keys", + "keysDescription": "Revoke a key immediately if it has been exposed.", + "emptyKeys": "You have not created an API key yet.", + "usage": "{today} requests today · {total} in the last 30 days", + "activity": "Created {created} · Last used {lastUsed}", + "never": "Never", + "revoke": "Revoke", + "revokeConfirm": "Revoke this API key? Applications using it will stop working immediately.", + "createTitle": "Create a key", + "keyAllowance": "{active} of {count} active keys used", + "keyName": "Key name", + "keyNamePlaceholder": "Production integration", + "create": "Create API key", + "actionFailed": "Unable to complete that action." + }, + "docs": { + "link": "Documentation", + "title": "Developer API documentation", + "introduction": "Build with Sharply gear data", + "backToPortal": "Back to API keys", + "authenticationTitle": "Authentication", + "authenticationDescription": "Send your API key in the Authorization header with every request.", + "serverOnlyNote": "请从您的服务器或其他受信任的后端使用 API。由于不会发送 CORS 标头,因此不支持浏览器请求;切勿在客户端代码中暴露有效的 API 密钥。", + "endpointsTitle": "Endpoints", + "searchTitle": "Search gear", + "searchDescription": "Returns a ranked page of published gear matching a query.", + "gearTitle": "Get gear detail", + "gearDescription": "Returns the complete published catalog record and its available related data.", + "parametersTitle": "Parameters", + "exampleRequest": "请求示例", + "exampleCurl": "cURL", + "exampleTypeScript": "TypeScript", + "responseTitle": "Response", + "required": "Required", + "optional": "Optional", + "searchQuery": "Search query. Use 2 to 200 characters.", + "searchPage": "Page number. Defaults to 1.", + "searchLimit": "Results per page. Defaults to 20; maximum 25.", + "gearSlug": "The published gear slug.", + "specCatalogTitle": "Browse available specs", + "specCatalogDescription": "Returns the live catalog of selectable fields and API categories.", + "specDictionaryTitle": "Available selectors", + "specDictionaryDescription": "Use a category ID to select every field in that category, or use an exact field ID to select one field. This list updates with the deployed spec registry.", + "selectedSpecsTitle": "Get selected specs", + "selectedSpecsDescription": "Returns selected fields or categories for one published gear item.", + "specFields": "One to 50 comma-separated field IDs or category IDs, such as camera.sensor.", + "errorsTitle": "Errors and limits", + "errorsDescription": "Requests return JSON errors for invalid input, missing or invalid keys, unpublished gear, and rate limits. Each key allows 60 requests per fixed UTC minute." + }, + "admin": { + "navLabel": "Developer API", + "eyebrow": "Administration", + "title": "Developer API access", + "description": "Grant access, manage keys, and review basic request totals.", + "secretTitle": "Copy the generated key now", + "secretDescription": "This secret is shown once. Give it to the account owner through a secure channel.", + "usersTitle": "Users", + "usersDescription": "Developer access is required before a user can receive or create API keys.", + "searchPlaceholder": "Search name or email", + "usage": "{today} today · {total} in 30 days · {keys} active keys", + "grantAccess": "Grant access", + "removeAccess": "Remove access", + "removeAccessConfirm": "Remove developer access? All active API keys for this user will be revoked immediately.", + "revokeKeyConfirm": "Revoke this API key immediately?", + "noKeys": "No active keys.", + "keyName": "New key name", + "keyNamePlaceholder": "Managed integration", + "createKey": "Create key", + "page": "Page {current} of {total}", + "previous": "Previous", + "next": "Next", + "actionFailed": "Unable to complete that action." + } + }, "metadata": { "siteDescription": "真实的规格,真实的评论,真实的速度。 Sharply 是最新、最全面的摄影器材数据库和评论平台,在现代、简约的界面中提供专家评论、真实规格和并排比较。", "signInTitle": "登录", diff --git a/src/app/[locale]/(admin)/admin/developer-api/developer-api-admin-manager.tsx b/src/app/[locale]/(admin)/admin/developer-api/developer-api-admin-manager.tsx new file mode 100644 index 00000000..fa6bd687 --- /dev/null +++ b/src/app/[locale]/(admin)/admin/developer-api/developer-api-admin-manager.tsx @@ -0,0 +1,304 @@ +"use client"; + +import { KeyRound, Search, ShieldCheck, ShieldOff, Trash2 } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + actionCreateDeveloperApiKeyForAdmin, + actionRevokeDeveloperApiKeyForAdmin, + actionSetDeveloperAccess, +} from "~/server/developer-api/actions"; + +type AdminUser = { + id: string; + name: string | null; + email: string; + developerAccessEnabled: boolean; + activeKeyCount: number; + usage: { today: number; last30Days: number }; +}; + +type AdminKey = { + id: string; + userId: string; + name: string; + keyPrefix: string; + revokedAt: Date | string | null; +}; + +export function DeveloperApiAdminManager({ + data, +}: { + data: { users: AdminUser[]; keys: AdminKey[] }; +}) { + const t = useTranslations("developerApi"); + const router = useRouter(); + const [query, setQuery] = useState(""); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); + const [secret, setSecret] = useState(null); + const [keyNames, setKeyNames] = useState>({}); + const [page, setPage] = useState(0); + const pageSize = 10; + + const filteredUsers = useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return data.users; + return data.users.filter((user) => + [user.name, user.email] + .filter(Boolean) + .some((value) => value!.toLowerCase().includes(normalized)), + ); + }, [data.users, query]); + const pageCount = Math.max(1, Math.ceil(filteredUsers.length / pageSize)); + const visibleUsers = filteredUsers.slice( + page * pageSize, + (page + 1) * pageSize, + ); + + function run(action: () => Promise<{ ok: boolean }>) { + setError(null); + startTransition(() => { + void action().then((result) => { + if (!result.ok) setError(t("admin.actionFailed")); + else router.refresh(); + }); + }); + } + + function updateAccess(user: AdminUser) { + const next = !user.developerAccessEnabled; + if (!next && !window.confirm(t("admin.removeAccessConfirm"))) return; + run(() => actionSetDeveloperAccess(user.id, next)); + } + + function createKey(formData: FormData) { + setError(null); + startTransition(() => { + void actionCreateDeveloperApiKeyForAdmin(formData).then((result) => { + if (!result.ok) setError(t("admin.actionFailed")); + else { + setSecret(result.secret); + const userId = formData.get("userId"); + if (typeof userId === "string") { + setKeyNames((names) => ({ ...names, [userId]: "" })); + } + router.refresh(); + } + }); + }); + } + + function revokeKey(keyId: string) { + if (!window.confirm(t("admin.revokeKeyConfirm"))) return; + run(() => actionRevokeDeveloperApiKeyForAdmin(keyId)); + } + + return ( +
+
+

+ {t("admin.eyebrow")} +

+

+ {t("admin.title")} +

+

{t("admin.description")}

+
+ + {secret ? ( + + + {t("admin.secretTitle")} + {t("admin.secretDescription")} + + + + {secret} + + + + + ) : null} + + + + {t("admin.usersTitle")} + {t("admin.usersDescription")} + + +
+ + { + setQuery(event.target.value); + setPage(0); + }} + className="pl-9" + placeholder={t("admin.searchPlaceholder")} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ {visibleUsers.map((user) => { + const keys = data.keys.filter( + (key) => key.userId === user.id && !key.revokedAt, + ); + return ( +
+
+
+

+ {user.name || user.email} +

+

+ {user.email} +

+

+ {t("admin.usage", { + today: user.usage.today, + total: user.usage.last30Days, + keys: user.activeKeyCount, + })} +

+
+ +
+ {user.developerAccessEnabled ? ( +
+
+ {keys.length === 0 ? ( +

+ {t("admin.noKeys")} +

+ ) : ( + keys.map((key) => ( +
+ + + {key.name}{" "} + + {key.keyPrefix}… + + + +
+ )) + )} +
+
+ + + + setKeyNames((names) => ({ + ...names, + [user.id]: event.target.value, + })) + } + required + maxLength={100} + placeholder={t("admin.keyNamePlaceholder")} + /> + +
+
+ ) : null} +
+ ); + })} +
+
+ + {t("admin.page", { current: page + 1, total: pageCount })} + +
+ + +
+
+
+
+
+ ); +} diff --git a/src/app/[locale]/(admin)/admin/developer-api/page.tsx b/src/app/[locale]/(admin)/admin/developer-api/page.tsx new file mode 100644 index 00000000..2c009533 --- /dev/null +++ b/src/app/[locale]/(admin)/admin/developer-api/page.tsx @@ -0,0 +1,16 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "~/auth"; +import { requireRole } from "~/lib/auth/auth-helpers"; +import { getDeveloperAdminData } from "~/server/developer-api/service"; +import { DeveloperApiAdminManager } from "./developer-api-admin-manager"; + +export const dynamic = "force-dynamic"; + +export default async function DeveloperApiAdminPage() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) redirect("/auth/signin?callbackUrl=/admin/developer-api"); + if (!requireRole(session.user, ["ADMIN"])) redirect("/admin"); + + return ; +} diff --git a/src/app/[locale]/(admin)/admin/sidebar.tsx b/src/app/[locale]/(admin)/admin/sidebar.tsx index c15b968a..e6bc1def 100644 --- a/src/app/[locale]/(admin)/admin/sidebar.tsx +++ b/src/app/[locale]/(admin)/admin/sidebar.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import { useTranslations } from "next-intl"; import type { UserRole } from "~/auth"; // import { // IconCamera, @@ -42,13 +43,14 @@ import { ListCheck, Lock, Plus, + SquareTerminal, Users, Video, - Wrench + Wrench, } from "lucide-react"; import Link from "next/link"; import { Button } from "~/components/ui/button"; -import { Dialog,DialogContent,DialogTrigger } from "~/components/ui/dialog"; +import { Dialog, DialogContent, DialogTrigger } from "~/components/ui/dialog"; import { Skeleton } from "~/components/ui/skeleton"; import { useSession } from "~/lib/auth/auth-client"; import { requireRole } from "~/lib/auth/auth-helpers"; @@ -116,9 +118,16 @@ const sidebarItems: SidebarItem[] = [ icon: , allowed: ["ADMIN", "SUPERADMIN"], }, + { + label: "developerApi", + href: "/admin/developer-api", + icon: , + allowed: ["ADMIN", "SUPERADMIN"], + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { + const t = useTranslations("developerApi"); const { data, isPending, error } = useSession(); if (isPending) { @@ -183,7 +192,10 @@ export function AppSidebar({ ...props }: React.ComponentProps) { className="data-[slot=sidebar-menu-item]:!p-1.5" > - {item.icon} {item.label} + {item.icon}{" "} + {item.label === "developerApi" + ? t("admin.navLabel") + : item.label} diff --git a/src/app/[locale]/(pages)/developer/developer-portal.tsx b/src/app/[locale]/(pages)/developer/developer-portal.tsx new file mode 100644 index 00000000..9397fd43 --- /dev/null +++ b/src/app/[locale]/(pages)/developer/developer-portal.tsx @@ -0,0 +1,291 @@ +"use client"; + +import { + Check, + Copy, + KeyRound, + Plus, + ShieldCheck, + SquareTerminal, + Trash2, +} from "lucide-react"; +import { useLocale, useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "~/components/ui/dialog"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import type { Locale } from "~/i18n/config"; +import { localizePathname } from "~/i18n/routing"; +import { + actionCreateDeveloperApiKey, + actionRevokeDeveloperApiKey, +} from "~/server/developer-api/actions"; + +type PortalKey = { + id: string; + name: string; + keyPrefix: string; + createdAt: Date | string; + lastUsedAt: Date | string | null; + usage: { today: number; last30Days: number }; +}; + +export function DeveloperPortal({ + data, +}: { + data: { + userName: string; + keyLimit: number; + rateLimit: number; + keys: PortalKey[]; + }; +}) { + const t = useTranslations("developerApi"); + const locale = useLocale() as Locale; + const router = useRouter(); + const [isPending, startTransition] = useTransition(); + const [secret, setSecret] = useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [keyName, setKeyName] = useState(""); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const activeKeyCount = data.keys.length; + const documentationHref = localizePathname("/developer/docs", locale); + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeZone: "UTC", + }), + [locale], + ); + + function createKey(formData: FormData) { + setError(null); + startTransition(() => { + void actionCreateDeveloperApiKey(formData).then((result) => { + if (!result.ok) { + setError(t("portal.actionFailed")); + return; + } + setKeyName(""); + setSecret(result.secret); + setCreateDialogOpen(false); + router.refresh(); + }); + }); + } + + function revokeKey(keyId: string) { + if (!window.confirm(t("portal.revokeConfirm"))) return; + setError(null); + startTransition(() => { + void actionRevokeDeveloperApiKey(keyId).then((result) => { + if (!result.ok) setError(t("portal.actionFailed")); + else router.refresh(); + }); + }); + } + + async function copySecret() { + if (!secret) return; + await navigator.clipboard.writeText(secret); + setCopied(true); + } + + return ( +
+
+

+

+ +
+ +
+

+ {t("portal.title")} +

+
+ + {t("portal.rateLimit", { count: data.rateLimit })} +
+
+ +
+
+

+ {t("portal.keyAllowance", { + count: data.keyLimit, + active: activeKeyCount, + })} +

+ +
+ +
+ {data.keys.length === 0 ? ( +

+ {t("portal.emptyKeys")} +

+ ) : ( + data.keys.map((key) => ( +
+
+
+ + {key.name} +
+

+ {key.keyPrefix}… +

+

+ {t("portal.usage", { + today: key.usage.today, + total: key.usage.last30Days, + })} +

+

+ {t("portal.activity", { + created: dateFormatter.format(new Date(key.createdAt)), + lastUsed: key.lastUsedAt + ? dateFormatter.format(new Date(key.lastUsedAt)) + : t("portal.never"), + })} +

+
+ +
+ )) + )} +
+
+ + { + setCreateDialogOpen(open); + if (!open) setError(null); + }} + > + + + {t("portal.createTitle")} + + {t("portal.keyAllowance", { + count: data.keyLimit, + active: activeKeyCount, + })} + + +
+
+ + setKeyName(event.target.value)} + required + maxLength={100} + placeholder={t("portal.keyNamePlaceholder")} + /> +
+ {error ? ( +

+ {error} +

+ ) : null} + +
+
+
+ + { + if (!open) { + setSecret(null); + setCopied(false); + } + }} + > + + + {t("portal.secretTitle")} + + {t("portal.secretDescription")} + + + {secret ? ( +
+ + {secret} + +
+ + +
+
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/[locale]/(pages)/developer/docs/page.tsx b/src/app/[locale]/(pages)/developer/docs/page.tsx new file mode 100644 index 00000000..e80915a0 --- /dev/null +++ b/src/app/[locale]/(pages)/developer/docs/page.tsx @@ -0,0 +1,338 @@ +import { ArrowLeft, BookOpen, KeyRound } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { auth } from "~/auth"; +import { Button } from "~/components/ui/button"; +import type { Locale } from "~/i18n/config"; +import { localizePathname } from "~/i18n/routing"; +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { getDeveloperApiSpecsCatalog } from "~/server/developer-api/specs"; +import { requireDeveloperPortalUser } from "~/server/developer-api/service"; +import { RequestExampleTabs } from "./request-example-tabs"; +import { SpecSelectorDialog } from "./spec-selector-dialog"; + +export const dynamic = "force-dynamic"; + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +function ParameterRow({ + name, + requirement, + description, +}: { + name: string; + requirement: string; + description: string; +}) { + return ( +
+
+ {name} + {requirement} +
+

+ {description} +

+
+ ); +} + +export default async function DeveloperDocsPage({ + params, +}: { + params: Promise<{ locale: Locale }>; +}) { + const { locale } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + const developerHref = localizePathname("/developer", locale); + if (!session?.user) { + redirect( + `${localizePathname("/auth/signin", locale)}?callbackUrl=${encodeURIComponent(localizePathname("/developer/docs", locale))}`, + ); + } + + try { + await requireDeveloperPortalUser(); + } catch (error) { + if ( + error instanceof DeveloperApiError && + error.code === "developer_access_required" + ) { + redirect(developerHref); + } + throw error; + } + + const t = await getTranslations({ locale, namespace: "developerApi.docs" }); + const specCatalog = getDeveloperApiSpecsCatalog(); + + return ( +
+
+
+
+
+

+ {t("introduction")} +

+
+ +
+ +
+

+ {t("authenticationTitle")} +

+

+ {t("authenticationDescription")} +

+

+ {t("serverOnlyNote")} +

+ {`Authorization: Bearer sharply_live_…`} +
+ +
+
+
+

+ {t("errorsDescription")} +

+
+ +
+

+ {t("endpointsTitle")} +

+
+ +
+
+ GET + /api/v1/search +
+

+ {t("searchTitle")} +

+

+ {t("searchDescription")} +

+
+

{t("parametersTitle")}

+
+ + + +
+

{t("exampleRequest")}

+ +

{t("responseTitle")}

+ {`{ + "data": [{ + "slug": "nikon-z6-iii", + "name": "Nikon Z6 III", + "brandName": "Nikon", + "gearType": "CAMERA" + }], + "pagination": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 } +}`} +
+
+ +
+
+ GET + /api/v1/gear/:slug +
+

+ {t("gearTitle")} +

+

+ {t("gearDescription")} +

+
+

{t("parametersTitle")}

+
+ +
+

{t("exampleRequest")}

+ +

{t("responseTitle")}

+ {`{ + "data": { + "slug": "nikon-z6-iii", + "name": "Nikon Z6 III", + "gearType": "CAMERA", + "cameraSpecs": { "resolutionMp": "24.5" } + } +}`} +
+
+ +
+
+ GET + /api/v1/gear/:slug/specs +
+

+ {t("selectedSpecsTitle")} +

+

+ {t("selectedSpecsDescription")} +

+
+

{t("parametersTitle")}

+
+ + +
+ +

{t("exampleRequest")}

+ +

{t("responseTitle")}

+ {`{ + "data": [{ + "id": "camera.sensor.isoRange", + "raw": { "min": 100, "max": 51200 }, + "display": "ISO 100–51,200" + }] +}`} +
+
+ +
+
+ GET + /api/v1/specs +
+

+ {t("specCatalogTitle")} +

+

+ {t("specCatalogDescription")} +

+
+

{t("exampleRequest")}

+ +

{t("responseTitle")}

+ {`{ + "data": { + "categories": [{ + "id": "camera.sensor", + "label": "Camera sensor", + "fields": [{ "id": "camera.sensor.isoRange", "label": "ISO Range" }] + }] + } +}`} +
+
+
+ ); +} diff --git a/src/app/[locale]/(pages)/developer/docs/request-example-tabs.tsx b/src/app/[locale]/(pages)/developer/docs/request-example-tabs.tsx new file mode 100644 index 00000000..f5fd07b5 --- /dev/null +++ b/src/app/[locale]/(pages)/developer/docs/request-example-tabs.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ); +} + +export function RequestExampleTabs({ + curl, + typescript, +}: { + curl: string; + typescript: string; +}) { + const t = useTranslations("developerApi.docs"); + + return ( + + + {t("exampleCurl")} + {t("exampleTypeScript")} + + + {curl} + + + {typescript} + + + ); +} diff --git a/src/app/[locale]/(pages)/developer/docs/spec-selector-dialog.tsx b/src/app/[locale]/(pages)/developer/docs/spec-selector-dialog.tsx new file mode 100644 index 00000000..abd74e1c --- /dev/null +++ b/src/app/[locale]/(pages)/developer/docs/spec-selector-dialog.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { ListTree } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "~/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; + +type SpecCategory = { + id: string; + label: string; + fields: Array<{ + id: string; + label: string; + searchTerms: string[]; + }>; +}; + +export function SpecSelectorDialog({ + categories, +}: { + categories: SpecCategory[]; +}) { + const t = useTranslations("developerApi.docs"); + + return ( + + + + + + + {t("specDictionaryTitle")} + + {t("specDictionaryDescription")} + + +
+
+ {categories.map((category) => ( +
+
+ + {category.id} + + + {category.label} + +
+
    + {category.fields.map((field) => ( +
  • + + {field.id} + + + {field.label} + +
  • + ))} +
+
+ ))} +
+
+
+
+ ); +} diff --git a/src/app/[locale]/(pages)/developer/page.tsx b/src/app/[locale]/(pages)/developer/page.tsx new file mode 100644 index 00000000..cd152267 --- /dev/null +++ b/src/app/[locale]/(pages)/developer/page.tsx @@ -0,0 +1,51 @@ +import { getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "~/auth"; +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { getDeveloperPortalData } from "~/server/developer-api/service"; +import { DeveloperPortal } from "./developer-portal"; + +export const dynamic = "force-dynamic"; + +export default async function DeveloperPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + redirect(`/${locale}/auth/signin?callbackUrl=/${locale}/developer`); + } + + const portalData = await getDeveloperPortalData().catch((error: unknown) => { + if ( + error instanceof DeveloperApiError && + error.code === "developer_access_required" + ) { + return null; + } + throw error; + }); + if (!portalData) { + const t = await getTranslations({ locale, namespace: "developerApi" }); + return ( +
+
+

+ {t("portal.eyebrow")} +

+

+ {t("portal.accessTitle")} +

+

+ {t("portal.accessDescription")} +

+
+
+ ); + } + + return ; +} diff --git a/src/app/api/v1/gear/[slug]/route.ts b/src/app/api/v1/gear/[slug]/route.ts new file mode 100644 index 00000000..0a40f415 --- /dev/null +++ b/src/app/api/v1/gear/[slug]/route.ts @@ -0,0 +1,21 @@ +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { serializeGear } from "~/server/developer-api/serializers"; +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { getDeveloperGear } from "~/server/developer-api/service"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + return runDeveloperApiRequest(request, "gear", async () => { + const { slug } = await params; + if (!slug.trim() || slug.length > 220) { + throw new DeveloperApiError( + "invalid_request", + 400, + "A valid gear slug is required.", + ); + } + return serializeGear(await getDeveloperGear(slug)); + }); +} diff --git a/src/app/api/v1/gear/[slug]/specs/route.ts b/src/app/api/v1/gear/[slug]/specs/route.ts new file mode 100644 index 00000000..d813c7fc --- /dev/null +++ b/src/app/api/v1/gear/[slug]/specs/route.ts @@ -0,0 +1,28 @@ +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { serializeDeveloperApiSpecs } from "~/server/developer-api/serializers"; +import { parseSpecSelectors } from "~/server/developer-api/schemas"; +import { getDeveloperGearSelectedSpecs } from "~/server/developer-api/specs"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> }, +) { + return runDeveloperApiRequest(request, "gear", async () => { + const { slug } = await params; + if (!slug.trim() || slug.length > 220) { + throw new DeveloperApiError( + "invalid_request", + 400, + "A valid gear slug is required.", + ); + } + const selectors = parseSpecSelectors(new URL(request.url).searchParams); + return { + ...serializeDeveloperApiSpecs( + await getDeveloperGearSelectedSpecs({ slug, selectors }), + ), + headers: { "Cache-Control": "no-store" }, + }; + }); +} diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts new file mode 100644 index 00000000..933636a4 --- /dev/null +++ b/src/app/api/v1/search/route.ts @@ -0,0 +1,15 @@ +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { serializeSearchResponse } from "~/server/developer-api/serializers"; +import { parseSearchParams } from "~/server/developer-api/schemas"; +import { searchDeveloperApi } from "~/server/developer-api/service"; + +export async function GET(request: Request) { + return runDeveloperApiRequest(request, "search", async () => { + const { query, page, limit } = parseSearchParams( + new URL(request.url).searchParams, + ); + return serializeSearchResponse( + await searchDeveloperApi(query, page, limit), + ); + }); +} diff --git a/src/app/api/v1/search/suggestions/route.ts b/src/app/api/v1/search/suggestions/route.ts new file mode 100644 index 00000000..e6fac6bb --- /dev/null +++ b/src/app/api/v1/search/suggestions/route.ts @@ -0,0 +1,15 @@ +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { serializeSuggestions } from "~/server/developer-api/serializers"; +import { parseSuggestionParams } from "~/server/developer-api/schemas"; +import { getDeveloperSuggestions } from "~/server/developer-api/service"; + +export async function GET(request: Request) { + return runDeveloperApiRequest(request, "suggestions", async () => { + const { query, limit, region } = parseSuggestionParams( + new URL(request.url).searchParams, + ); + return serializeSuggestions( + await getDeveloperSuggestions(query, limit, region), + ); + }); +} diff --git a/src/app/api/v1/specs/route.ts b/src/app/api/v1/specs/route.ts new file mode 100644 index 00000000..2bf2ca19 --- /dev/null +++ b/src/app/api/v1/specs/route.ts @@ -0,0 +1,9 @@ +import { runDeveloperApiRequest } from "~/server/developer-api/http"; +import { getDeveloperApiSpecsCatalog } from "~/server/developer-api/specs"; + +export async function GET(request: Request) { + return runDeveloperApiRequest(request, "gear", async () => ({ + data: { categories: getDeveloperApiSpecsCatalog() }, + headers: { "Cache-Control": "no-store" }, + })); +} diff --git a/src/components/layout/header-client.tsx b/src/components/layout/header-client.tsx index ec16d482..95484482 100644 --- a/src/components/layout/header-client.tsx +++ b/src/components/layout/header-client.tsx @@ -7,7 +7,10 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import Logo from "public/logo"; -import type { HeaderUser, HeaderViewModel } from "~/components/layout/header-model"; +import type { + HeaderUser, + HeaderViewModel, +} from "~/components/layout/header-model"; import { buildHeaderCallbackUrl, buildHeaderRouteState, @@ -34,7 +37,9 @@ function HeaderSearchParamsSync({ onNormalizedSearchChange: (value: string) => void; }) { const searchParams = useSearchParams(); - const normalizedSearch = searchParams.toString() ? `?${searchParams.toString()}` : ""; + const normalizedSearch = searchParams.toString() + ? `?${searchParams.toString()}` + : ""; useEffect(() => { onNormalizedSearchChange(normalizedSearch); @@ -75,11 +80,14 @@ export default function HeaderClient({ name: session.user.name ?? null, email: session.user.email ?? null, image: session.user.image ?? null, + developerAccessEnabled: session.user.developerAccessEnabled ?? false, } : null; const isAdminOrEditor = - user?.role === "ADMIN" || user?.role === "SUPERADMIN" || user?.role === "EDITOR"; + user?.role === "ADMIN" || + user?.role === "SUPERADMIN" || + user?.role === "EDITOR"; const profileHref = (() => { if (!user) return null; @@ -92,9 +100,12 @@ export default function HeaderClient({ return profileSlug ? localizePathname(`/u/${profileSlug}`, locale) : null; })(); - const { hasScrolled } = useScrollState(routeState.scrollResponsive ? 290 : Infinity); + const { hasScrolled } = useScrollState( + routeState.scrollResponsive ? 290 : Infinity, + ); const shouldShowHeaderSearch = - routeState.initialMode === "compact" || (routeState.scrollResponsive && hasScrolled); + routeState.initialMode === "compact" || + (routeState.scrollResponsive && hasScrolled); const sheetTopClass = shouldShowHeaderSearch ? "top-16 h-[calc(100vh-4rem)]" : "top-24 h-[calc(100vh-6rem)]"; @@ -221,6 +232,7 @@ export default function HeaderClient({ labels={model.labels} profileHref={profileHref} accountHref={model.accountHref} + developerHref={model.developerHref} /> ) : ( diff --git a/src/components/layout/header-model.ts b/src/components/layout/header-model.ts index 5edcea8d..7b608436 100644 --- a/src/components/layout/header-model.ts +++ b/src/components/layout/header-model.ts @@ -35,6 +35,7 @@ export type HeaderUser = { name?: string | null; email?: string | null; image?: string | null; + developerAccessEnabled?: boolean; } | null; export type HeaderNotificationsData = { @@ -79,6 +80,7 @@ export type HeaderFooterItems = HeaderFooterItemsSource; export type HeaderLabels = { adminPanel: string; + developerPortal: string; signIn: string; profile: string; account: string; @@ -90,6 +92,7 @@ export type HeaderViewModel = { homeHref: string; adminHref: string; accountHref: string; + developerHref: string; labels: HeaderLabels; navItems: HeaderNavItem[]; footerItems: HeaderFooterItems; @@ -101,7 +104,9 @@ export function buildHeaderRouteState(normalizedPathname: string): { scrollResponsive: boolean; } { const isHomePage = normalizedPathname === "/"; - const isSearchResultsPage = normalizedPathname === "/search" || normalizedPathname.startsWith("/search/"); + const isSearchResultsPage = + normalizedPathname === "/search" || + normalizedPathname.startsWith("/search/"); if (isHomePage) { return { @@ -183,6 +188,7 @@ export function buildHeaderViewModel({ homeHref: localizePathname("/", locale), adminHref: localizePathname("/admin", locale), accountHref: localizePathname("/profile/settings", locale), + developerHref: localizePathname("/developer", locale), labels, navItems: localizeHeaderNavItems(navItems, locale), footerItems: localizeHeaderFooterItems(footerItems, locale), diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx index f7b26e62..aef1543f 100644 --- a/src/components/layout/header.tsx +++ b/src/components/layout/header.tsx @@ -18,6 +18,7 @@ export default async function Header({ locale }: { locale: Locale }) { footerItems: getFooterItems(tNav), labels: { adminPanel: tCommon("adminPanel"), + developerPortal: tCommon("developerPortal"), signIn: tCommon("signIn"), profile: tCommon("profile"), account: tCommon("account"), diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx index 63916f0e..8f47e53c 100644 --- a/src/components/layout/user-menu.tsx +++ b/src/components/layout/user-menu.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar,AvatarFallback,AvatarImage } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, DropdownMenuContent, @@ -9,9 +9,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { LogOut,Settings,User as UserIcon } from "lucide-react"; +import { + LogOut, + Settings, + SquareTerminal, + User as UserIcon, +} from "lucide-react"; import Link from "next/link"; -import { useMemo,useState } from "react"; +import { useMemo, useState } from "react"; import type { HeaderLabels } from "~/components/layout/header-model"; import { Spinner } from "~/components/ui/spinner"; import { logOut } from "~/lib/auth"; @@ -24,6 +29,7 @@ export type UserMenuUser = { name?: string | null; email?: string | null; image?: string | null; + developerAccessEnabled?: boolean; } | null; export function UserMenu({ @@ -31,11 +37,16 @@ export function UserMenu({ labels, profileHref, accountHref, + developerHref, }: { user: UserMenuUser; - labels: Pick; + labels: Pick< + HeaderLabels, + "account" | "anonymous" | "developerPortal" | "logOut" | "profile" + >; profileHref: string | null; accountHref: string; + developerHref: string; }) { const initials = useMemo(() => { const source = user?.name || user?.email || "?"; @@ -88,14 +99,22 @@ export function UserMenu({ - + {labels.account} + {user.developerAccessEnabled ? ( + + + + {labels.developerPortal} + + + ) : null} ; diff --git a/src/lib/mapping/sensor-map.ts b/src/lib/mapping/sensor-map.ts index 4bed464b..54891234 100644 --- a/src/lib/mapping/sensor-map.ts +++ b/src/lib/mapping/sensor-map.ts @@ -1,6 +1,11 @@ import { SENSOR_FORMATS } from "~/lib/constants"; import type { CameraSpecs } from "~/types/gear"; +export type SensorTypeSource = Pick< + CameraSpecs, + "sensorStackingType" | "sensorTechType" | "isBackSideIlluminated" +>; + // const FORMATS = SENSOR_FORMATS as Array< // Pick // >; @@ -29,7 +34,9 @@ export function sensorSlugFromId( return match?.slug; } -export function sensorTypeLabel(cameraSpecs: CameraSpecs): string { +export function sensorTypeLabel( + cameraSpecs: SensorTypeSource | null | undefined, +): string { if (!cameraSpecs) { console.warn("[sensorTypeLabel] cameraSpecs is null"); return ""; diff --git a/src/lib/security/botid-protected-routes.ts b/src/lib/security/botid-protected-routes.ts index a3823493..89c514a7 100644 --- a/src/lib/security/botid-protected-routes.ts +++ b/src/lib/security/botid-protected-routes.ts @@ -23,6 +23,14 @@ export const botIdProtectedRoutes = [ path: "/*/exif-viewer/parse", method: "POST", }, + { + path: "/lists/under-construction", + method: "POST", + }, + { + path: "/*/lists/under-construction", + method: "POST", + }, { path: "/gear/*", method: "POST", diff --git a/src/lib/specs/registry.tsx b/src/lib/specs/registry.tsx index eba97219..573697c0 100644 --- a/src/lib/specs/registry.tsx +++ b/src/lib/specs/registry.tsx @@ -1,18 +1,16 @@ import type { SpecsTableSection } from "~/app/[locale]/(pages)/gear/_components/specs-table"; import { VideoSpecsSummary } from "~/app/[locale]/(pages)/gear/_components/video/video-summary"; import { Badge } from "~/components/ui/badge"; -import { - formatDateWithPrecision, - type DatePrecision, -} from "~/lib/format/date"; +import { isValidElement, type ReactNode } from "react"; +import { formatDateWithPrecision, type DatePrecision } from "~/lib/format/date"; import { type GearRegion } from "~/lib/gear/region"; -import { AF_AREA_MODES,MOUNTS } from "~/lib/generated"; +import { AF_AREA_MODES, MOUNTS } from "~/lib/generated"; import { formatCameraType, formatCardSlotDetails, formatPrecaptureSupport, formatPrice, - formatShutterType + formatShutterType, } from "~/lib/mapping"; import { formatAnalogCameraType, @@ -30,17 +28,19 @@ import { formatFilterType } from "~/lib/mapping/filter-types-map"; import { formatFocalLengthRangeDisplay } from "~/lib/mapping/focal-length-map"; import { formatFocusDistance } from "~/lib/mapping/focus-distance-map"; import { formatMaxFpsDisplay } from "~/lib/mapping/max-fps-map"; +import { getMountLongNameById } from "~/lib/mapping/mounts-map"; import { - getMountLongNameById -} from "~/lib/mapping/mounts-map"; -import { sensorNameFromId,sensorTypeLabel } from "~/lib/mapping/sensor-map"; + sensorNameFromId, + sensorTypeLabel, + type SensorTypeSource, +} from "~/lib/mapping/sensor-map"; import { cn } from "~/lib/utils"; import { normalizedToCameraVideoModes, type VideoModeNormalized, } from "~/lib/video/mode-schema"; import { buildVideoDisplayBundle } from "~/lib/video/transform"; -import type { CameraVideoMode,GearAlias,GearItem } from "~/types/gear"; +import type { CameraVideoMode, GearAlias, GearItem } from "~/types/gear"; import { supportsVideoMeaningfully } from "./helpers"; export type SpecTranslator = ((key: string) => string) & { @@ -98,11 +98,15 @@ function formatWeightGrams( if (n >= 1000) { const kg = n / 1000; - const formattedKg = Number.isInteger(kg) ? String(kg) : String(Number(kg.toFixed(2))); + const formattedKg = Number.isInteger(kg) + ? String(kg) + : String(Number(kg.toFixed(2))); return `${formattedKg} kg`; } - const formattedGrams = Number.isInteger(n) ? String(n) : String(Number(n.toFixed(1))); + const formattedGrams = Number.isInteger(n) + ? String(n) + : String(Number(n.toFixed(1))); return `${formattedGrams} g`; } @@ -186,7 +190,9 @@ function getVideoNotes(item: GearItem): string | null { return null; } -function uniqueNonEmptyStrings(values: Array): string[] { +function uniqueNonEmptyStrings( + values: Array, +): string[] { return Array.from( new Set( values @@ -250,7 +256,8 @@ function getFieldLabelKey( field: SpecFieldDef, ): string { return ( - field.labelKey ?? `specRegistry.sections.${section.id}.fields.${field.key}.label` + field.labelKey ?? + `specRegistry.sections.${section.id}.fields.${field.key}.label` ); } @@ -304,6 +311,37 @@ export type SpecFieldDef = { condition?: (item: GearItem) => boolean; // Optional: when to show this field hideInSpecsTable?: boolean; // Optional: keep field available to edit/navigation but hide from public specs table condenseOnMobile?: boolean; // Whether to condense the field on mobile + /** Set to null to exclude a website spec from the developer API. */ + api?: null; +}; + +export type DeveloperApiSpecMetadata = { + /** Stable public field identifier. */ + id: string; + /** Stable API grouping, intentionally independent from website sections. */ + category: string; + /** English display label for the API category. */ + categoryLabel: string; + /** + * Optional text-only exception for a field whose website formatter renders + * interactive or otherwise non-text UI. Normal fields reuse formatDisplay. + */ + displayOverride?: (raw: unknown, item: GearItem) => string | undefined; +}; + +export type DeveloperApiSpecField = { + section: SpecSectionDef; + field: Omit & { api: DeveloperApiSpecMetadata }; +}; + +export type DeveloperApiSpecCategory = { + id: string; + label: string; + fields: Array<{ + id: string; + label: string; + searchTerms: string[]; + }>; }; export type SpecSectionDef = { @@ -389,7 +427,8 @@ export const specDictionary: SpecSectionDef[] = [ item.announcedDate ? formatDateWithPrecision(item.announcedDate, { locale: locale ?? "en", - precision: (item.announceDatePrecision ?? "DAY") as DatePrecision, + precision: (item.announceDatePrecision ?? + "DAY") as DatePrecision, }) : undefined, editElementId: "announced-date", @@ -403,7 +442,8 @@ export const specDictionary: SpecSectionDef[] = [ item.releaseDate ? formatDateWithPrecision(item.releaseDate, { locale: locale ?? "en", - precision: (item.releaseDatePrecision ?? "DAY") as DatePrecision, + precision: (item.releaseDatePrecision ?? + "DAY") as DatePrecision, }) : undefined, editElementId: "release-date", @@ -606,7 +646,13 @@ export const specDictionary: SpecSectionDef[] = [ { key: "maxFpsByShutter", label: "Max Continuous FPS", - searchTerms: ["fps", "burst", "burst rate", "continuous shooting", "frame rate"], + searchTerms: [ + "fps", + "burst", + "burst rate", + "continuous shooting", + "frame rate", + ], condenseOnMobile: true, getRawValue: (item) => ({ perShutter: item.cameraSpecs?.maxFpsByShutter, @@ -620,10 +666,14 @@ export const specDictionary: SpecSectionDef[] = [ { key: "sensorType", label: "Sensor Type", - getRawValue: (item) => item.cameraSpecs, + getRawValue: (item) => ({ + sensorStackingType: item.cameraSpecs?.sensorStackingType, + sensorTechType: item.cameraSpecs?.sensorTechType, + isBackSideIlluminated: item.cameraSpecs?.isBackSideIlluminated, + }), formatDisplay: (raw) => { if (!raw) return undefined; - const label = sensorTypeLabel(raw as any); + const label = sensorTypeLabel(raw as SensorTypeSource); return label && label.trim().length > 0 ? label : undefined; }, editElementId: "sensorStackingType", @@ -640,7 +690,12 @@ export const specDictionary: SpecSectionDef[] = [ { key: "maxRawBitDepth", label: "Max Raw Bit Depth", - searchTerms: ["raw", "raw photo", "photo bit depth", "stills bit depth"], + searchTerms: [ + "raw", + "raw photo", + "photo bit depth", + "stills bit depth", + ], getRawValue: (item) => item.cameraSpecs?.maxRawBitDepth, formatDisplay: (raw) => typeof raw === "string" ? `${raw}-bit` : undefined, @@ -1140,7 +1195,12 @@ export const specDictionary: SpecSectionDef[] = [ { key: "afSubjectCategories", label: "AF Subject Categories", - searchTerms: ["autofocus", "af", "subject detection", "subject recognition"], + searchTerms: [ + "autofocus", + "af", + "subject detection", + "subject recognition", + ], getRawValue: (item) => item.cameraSpecs?.afSubjectCategories, formatDisplay: (raw, _, forceLeftAlign) => { if (!Array.isArray(raw)) return undefined; @@ -1226,6 +1286,7 @@ export const specDictionary: SpecSectionDef[] = [ { key: "videoSummary", label: "Video Summary", + api: null, condenseOnMobile: true, editElementId: "video-modes-manager", getRawValue: (item) => item.videoModes, @@ -1247,6 +1308,8 @@ export const specDictionary: SpecSectionDef[] = [ { key: "videoAvailableCodecs", label: "Available Codecs", + // Video-mode records are intentionally not part of the beta API. + api: null, getRawValue: (item) => item.videoModes, formatDisplay: (_, item) => { const list = Array.from( @@ -1555,8 +1618,7 @@ export const specDictionary: SpecSectionDef[] = [ key: "focusMotorType", label: "Focus Motor Type", getRawValue: (item) => item.lensSpecs?.focusMotorType, - formatDisplay: (raw) => - typeof raw === "string" ? (raw) : undefined, + formatDisplay: (raw) => (typeof raw === "string" ? raw : undefined), condition: (item) => item.lensSpecs?.hasAutofocus === true, }, { @@ -1623,7 +1685,13 @@ export const specDictionary: SpecSectionDef[] = [ { key: "hasStabilization", label: "Has Image Stabilization", - searchTerms: ["stabilization", "ois", "vr", "os", "image stabilization"], + searchTerms: [ + "stabilization", + "ois", + "vr", + "os", + "image stabilization", + ], getRawValue: (item) => item.lensSpecs?.hasStabilization, formatDisplay: (raw) => typeof raw === "boolean" ? yesNoNull(raw) : undefined, @@ -2118,6 +2186,173 @@ export const specDictionary: SpecSectionDef[] = [ }, ]; +// ============================================================================ +// DEVELOPER API SPEC REGISTRY +// ============================================================================ + +type DeveloperApiSectionConfig = { + category: string; + label: string; + fieldCategories?: Record< + string, + Pick + >; + displayOverrides?: Record< + string, + (raw: unknown, item: GearItem) => string | undefined + >; +}; + +/** + * The public API has its own stable, purpose-built grouping layer. It maps + * website sections into categories that are useful to API consumers without + * constraining how the specs table is organized or displayed. + */ +const developerApiSectionConfig: Record = { + core: { category: "gear.basics", label: "Gear basics" }, + "camera-sensor-shutter": { + category: "camera.sensor", + label: "Camera sensor", + fieldCategories: { + maxFpsByShutter: { category: "camera.shutter", label: "Camera shutter" }, + shutterSpeedMax: { category: "camera.shutter", label: "Camera shutter" }, + shutterSpeedMin: { category: "camera.shutter", label: "Camera shutter" }, + flashSyncSpeed: { category: "camera.shutter", label: "Camera shutter" }, + hasSilentShootingAvailable: { + category: "camera.shutter", + label: "Camera shutter", + }, + availableShutterTypes: { + category: "camera.shutter", + label: "Camera shutter", + }, + }, + }, + "fixed-lens": { category: "camera.fixed-lens", label: "Integrated lens" }, + "camera-hardware": { category: "camera.build", label: "Camera build" }, + "camera-focus": { category: "camera.focus", label: "Camera focus" }, + "camera-battery": { category: "camera.power", label: "Camera power" }, + "camera-video": { + category: "camera.video", + label: "Camera video", + displayOverrides: { + videoSummary: (_raw, item) => { + const modes = coerceCameraVideoModes(item.videoModes); + if (!modes.length) return undefined; + const summaryLines = buildVideoDisplayBundle(modes).summaryLines; + return summaryLines.length ? summaryLines.join(" · ") : undefined; + }, + }, + }, + "camera-misc": { category: "camera.features", label: "Camera features" }, + "lens-optics": { category: "lens.optics", label: "Lens optics" }, + "lens-aperture": { category: "lens.aperture", label: "Lens aperture" }, + "lens-focus": { category: "lens.focus", label: "Lens focus" }, + "lens-stabilization": { + category: "lens.stabilization", + label: "Lens stabilization", + }, + "lens-build": { category: "lens.build", label: "Lens build" }, + "lens-filters": { category: "lens.filters", label: "Lens filters" }, + "lens-accessories": { + category: "lens.accessories", + label: "Lens accessories", + }, + "lens-tilt-shift": { category: "lens.tiltShift", label: "Lens tilt-shift" }, + "analog-camera": { category: "analog.camera", label: "Analog camera" }, +}; + +function getDeveloperApiMetadata( + section: SpecSectionDef, + field: SpecFieldDef, +): DeveloperApiSpecMetadata | undefined { + const config = developerApiSectionConfig[section.id]; + if (!config || field.hideInSpecsTable || field.api === null) return undefined; + const category = config.fieldCategories?.[field.key] ?? config; + return { + id: `${category.category}.${field.key}`, + category: category.category, + categoryLabel: category.label, + displayOverride: config.displayOverrides?.[field.key], + }; +} + +/** Returns live public API fields in their stable API category and field order. */ +export function getDeveloperApiSpecFields(): DeveloperApiSpecField[] { + const fields: DeveloperApiSpecField[] = []; + for (const section of specDictionary) { + for (const field of section.fields) { + const api = getDeveloperApiMetadata(section, field); + if (!api) continue; + fields.push({ section, field: { ...field, api } }); + } + } + return fields; +} + +/** Generates the developer catalog from the currently deployed spec registry. */ +export function getDeveloperApiSpecCatalog(): DeveloperApiSpecCategory[] { + const categories = new Map(); + for (const { field } of getDeveloperApiSpecFields()) { + const category = categories.get(field.api.category) ?? { + id: field.api.category, + label: field.api.categoryLabel, + fields: [], + }; + category.fields.push({ + id: field.api.id, + label: field.label, + searchTerms: field.searchTerms ?? [], + }); + categories.set(category.id, category); + } + return [...categories.values()]; +} + +function textFromSpecDisplay(value: ReactNode): string | undefined { + if (value == null || typeof value === "boolean") return undefined; + if (typeof value === "string" || typeof value === "number") + return String(value); + if (Array.isArray(value)) { + const values = value + .map((child) => textFromSpecDisplay(child)) + .filter((child): child is string => Boolean(child)); + return values.length ? values.join(" ") : undefined; + } + if (isValidElement(value)) { + return textFromSpecDisplay( + (value.props as { children?: ReactNode }).children, + ); + } + return undefined; +} + +/** + * Derives the API's English text display from the website formatter by + * default. Fields with interactive website UI can opt into api.displayOverride. + */ +export function getDeveloperApiSpecValue( + item: GearItem, + definition: DeveloperApiSpecField, +) { + const { field } = definition; + if (definition.section.condition && !definition.section.condition(item)) { + return undefined; + } + if (field.condition && !field.condition(item)) return undefined; + + const raw = field.getRawValue(item); + const formatted = field.api.displayOverride + ? field.api.displayOverride(raw, item) + : field.formatDisplay + ? field.formatDisplay(raw, item, true, "GLOBAL", "en") + : raw; + const display = textFromSpecDisplay(formatted as ReactNode); + if (!display?.trim()) return undefined; + + return { id: field.api.id, raw, display }; +} + // ============================================================================ // HELPER FUNCTIONS // ============================================================================ @@ -2162,7 +2397,13 @@ export function buildGearSpecsSections( .map((field) => { const raw = field.getRawValue(item); const rawValue = field.formatDisplay - ? field.formatDisplay(raw, item, forceLeftAlign, viewerRegion, locale) + ? field.formatDisplay( + raw, + item, + forceLeftAlign, + viewerRegion, + locale, + ) : (raw as React.ReactNode); const value = typeof rawValue === "string" diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index f020e204..888e8452 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -2529,6 +2529,11 @@ export const users = appSchema.table("user", (d) => ({ inviteId: varchar("invite_id", { length: 36 }), // Social links (array of {label: string, url: string, icon?: string}) socialLinks: jsonb("social_links"), + // Developer API access is explicitly granted by an administrator. + developerAccessEnabled: d + .boolean("developer_access_enabled") + .notNull() + .default(false), createdAt, updatedAt: d .timestamp("updated_at") @@ -2554,6 +2559,77 @@ export const usersRelations = relations(users, ({ many }) => ({ // Export the user type for use throughout the application export type User = typeof users.$inferSelect; +// -- DEVELOPER API -- + +/** + * User-owned credentials for the public developer API. The plaintext secret + * is never persisted: only a SHA-256 digest and non-sensitive display prefix + * are stored. + */ +export const developerApiKeys = appSchema.table( + "developer_api_keys", + (d) => ({ + id: d + .varchar({ length: 36 }) + .primaryKey() + .default(sql`gen_random_uuid()::text`), + userId: d + .varchar("user_id", { length: 255 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + name: d.varchar({ length: 100 }).notNull(), + keyPrefix: d.varchar("key_prefix", { length: 48 }).notNull(), + keyHash: d.varchar("key_hash", { length: 64 }).notNull().unique(), + lastUsedAt: d.timestamp("last_used_at", { withTimezone: true }), + revokedAt: d.timestamp("revoked_at", { withTimezone: true }), + revokedByUserId: d + .varchar("revoked_by_user_id", { length: 255 }) + .references(() => users.id, { onDelete: "set null" }), + createdAt, + }), + (t) => [ + index("developer_api_keys_user_active_idx").on(t.userId, t.revokedAt), + index("developer_api_keys_last_used_idx").on(t.lastUsedAt), + ], +); + +/** Fixed UTC-minute counters used to atomically enforce per-key rate limits. */ +export const developerApiRateLimitBuckets = appSchema.table( + "developer_api_rate_limit_buckets", + (d) => ({ + apiKeyId: d + .varchar("api_key_id", { length: 36 }) + .notNull() + .references(() => developerApiKeys.id, { onDelete: "cascade" }), + windowStart: d.timestamp("window_start", { withTimezone: true }).notNull(), + requestCount: d.integer("request_count").notNull().default(0), + }), + (t) => [ + primaryKey({ columns: [t.apiKeyId, t.windowStart] }), + index("developer_api_rate_limit_buckets_window_idx").on(t.windowStart), + ], +); + +/** Daily request aggregates for the portal, admin view, and later billing. */ +export const developerApiUsageDaily = appSchema.table( + "developer_api_usage_daily", + (d) => ({ + apiKeyId: d + .varchar("api_key_id", { length: 36 }) + .notNull() + .references(() => developerApiKeys.id, { onDelete: "cascade" }), + usageDate: d.date("usage_date", { mode: "date" }).notNull(), + totalRequests: d.integer("total_requests").notNull().default(0), + searchRequests: d.integer("search_requests").notNull().default(0), + suggestionRequests: d.integer("suggestion_requests").notNull().default(0), + gearRequests: d.integer("gear_requests").notNull().default(0), + }), + (t) => [ + primaryKey({ columns: [t.apiKeyId, t.usageDate] }), + index("developer_api_usage_daily_date_idx").on(t.usageDate), + ], +); + export const authSessions = appSchema.table( "auth_sessions", { diff --git a/src/server/developer-api/actions.ts b/src/server/developer-api/actions.ts new file mode 100644 index 00000000..011e707d --- /dev/null +++ b/src/server/developer-api/actions.ts @@ -0,0 +1,81 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { DeveloperApiError } from "./errors"; +import { + createDeveloperApiKey, + createDeveloperApiKeyForAdmin, + revokeDeveloperApiKey, + revokeDeveloperApiKeyForAdmin, + setDeveloperAccessForUser, +} from "./service"; + +function actionError(error: unknown) { + if (error instanceof DeveloperApiError) { + return { ok: false as const, code: error.code, message: error.message }; + } + console.error("Developer API action failed:", error); + return { + ok: false as const, + code: undefined, + message: undefined, + }; +} + +export async function actionCreateDeveloperApiKey(formData: FormData) { + try { + const result = await createDeveloperApiKey({ name: formData.get("name") }); + revalidatePath("/developer"); + return { ok: true as const, secret: result.secret }; + } catch (error) { + return actionError(error); + } +} + +export async function actionRevokeDeveloperApiKey(keyId: string) { + try { + await revokeDeveloperApiKey(keyId); + revalidatePath("/developer"); + return { ok: true as const }; + } catch (error) { + return actionError(error); + } +} + +export async function actionSetDeveloperAccess( + userId: string, + enabled: boolean, +) { + try { + await setDeveloperAccessForUser(userId, enabled); + revalidatePath("/admin/developer-api"); + return { ok: true as const }; + } catch (error) { + return actionError(error); + } +} + +export async function actionCreateDeveloperApiKeyForAdmin(formData: FormData) { + try { + const userId = formData.get("userId"); + if (typeof userId !== "string") throw new Error("Missing user id."); + const result = await createDeveloperApiKeyForAdmin({ + userId, + name: formData.get("name"), + }); + revalidatePath("/admin/developer-api"); + return { ok: true as const, secret: result.secret }; + } catch (error) { + return actionError(error); + } +} + +export async function actionRevokeDeveloperApiKeyForAdmin(keyId: string) { + try { + await revokeDeveloperApiKeyForAdmin(keyId); + revalidatePath("/admin/developer-api"); + return { ok: true as const }; + } catch (error) { + return actionError(error); + } +} diff --git a/src/server/developer-api/constants.ts b/src/server/developer-api/constants.ts new file mode 100644 index 00000000..4b6fa6c3 --- /dev/null +++ b/src/server/developer-api/constants.ts @@ -0,0 +1,13 @@ +export const DEVELOPER_API_KEY_PREFIX = "sharply_live_"; +export const DEVELOPER_API_KEY_DISPLAY_LENGTH = 20; +export const DEVELOPER_API_MAX_ACTIVE_KEYS = 3; +export const DEVELOPER_API_RATE_LIMIT = 60; +export const DEVELOPER_API_RATE_LIMIT_WINDOW_MS = 60_000; + +export const DEVELOPER_API_ENDPOINTS = [ + "search", + "suggestions", + "gear", +] as const; + +export type DeveloperApiEndpoint = (typeof DEVELOPER_API_ENDPOINTS)[number]; diff --git a/src/server/developer-api/data.ts b/src/server/developer-api/data.ts new file mode 100644 index 00000000..3b9ef98e --- /dev/null +++ b/src/server/developer-api/data.ts @@ -0,0 +1,256 @@ +import "server-only"; + +import { and, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; +import { db } from "~/server/db"; +import { + developerApiKeys, + developerApiRateLimitBuckets, + developerApiUsageDaily, + users, +} from "~/server/db/schema"; +import type { DeveloperApiEndpoint } from "./constants"; + +export type DeveloperApiKeyRow = typeof developerApiKeys.$inferSelect; + +export async function findUsableApiKeyByHash(keyHash: string) { + const rows = await db + .select({ + key: developerApiKeys, + developerAccessEnabled: users.developerAccessEnabled, + }) + .from(developerApiKeys) + .innerJoin(users, eq(developerApiKeys.userId, users.id)) + .where( + and( + eq(developerApiKeys.keyHash, keyHash), + isNull(developerApiKeys.revokedAt), + ), + ) + .limit(1); + + return rows[0] ?? null; +} + +export async function countActiveApiKeysForUser(userId: string) { + const rows = await db + .select({ count: sql`count(*)` }) + .from(developerApiKeys) + .where( + and( + eq(developerApiKeys.userId, userId), + isNull(developerApiKeys.revokedAt), + ), + ); + return Number(rows[0]?.count ?? 0); +} + +export async function createApiKeyData(params: { + userId: string; + name: string; + keyPrefix: string; + keyHash: string; +}) { + const rows = await db.insert(developerApiKeys).values(params).returning(); + return rows[0]!; +} + +/** + * Creates a key only when the user remains below the active-key limit. The + * transaction-scoped advisory lock serializes this check per user so concurrent + * create requests cannot both consume the last available slot. + */ +export async function createApiKeyWithinActiveLimitData( + params: { + userId: string; + name: string; + keyPrefix: string; + keyHash: string; + }, + maxActiveKeys: number, +) { + return db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext('developer-api-keys'), hashtext(${params.userId}))`, + ); + + const rows = await tx + .select({ count: sql`count(*)` }) + .from(developerApiKeys) + .where( + and( + eq(developerApiKeys.userId, params.userId), + isNull(developerApiKeys.revokedAt), + ), + ); + + if (Number(rows[0]?.count ?? 0) >= maxActiveKeys) return null; + + const created = await tx + .insert(developerApiKeys) + .values(params) + .returning(); + return created[0]!; + }); +} + +export async function listApiKeysForUser(userId: string) { + return db + .select() + .from(developerApiKeys) + .where(eq(developerApiKeys.userId, userId)) + .orderBy(desc(developerApiKeys.createdAt)); +} + +export async function listAllApiKeysData() { + return db + .select() + .from(developerApiKeys) + .orderBy(desc(developerApiKeys.createdAt)); +} + +export async function revokeApiKeyData(params: { + keyId: string; + revokedByUserId: string; + userId?: string; +}) { + const conditions = [ + eq(developerApiKeys.id, params.keyId), + isNull(developerApiKeys.revokedAt), + ]; + if (params.userId) + conditions.push(eq(developerApiKeys.userId, params.userId)); + + const rows = await db + .update(developerApiKeys) + .set({ revokedAt: new Date(), revokedByUserId: params.revokedByUserId }) + .where(and(...conditions)) + .returning({ id: developerApiKeys.id }); + return rows[0] ?? null; +} + +export async function revokeAllApiKeysForUser( + userId: string, + revokedByUserId: string, +) { + await db + .update(developerApiKeys) + .set({ revokedAt: new Date(), revokedByUserId }) + .where( + and( + eq(developerApiKeys.userId, userId), + isNull(developerApiKeys.revokedAt), + ), + ); +} + +export async function setDeveloperAccessData(userId: string, enabled: boolean) { + const rows = await db + .update(users) + .set({ developerAccessEnabled: enabled, updatedAt: new Date() }) + .where(eq(users.id, userId)) + .returning({ + id: users.id, + developerAccessEnabled: users.developerAccessEnabled, + }); + return rows[0] ?? null; +} + +export async function getDeveloperAccessData(userId: string) { + const rows = await db + .select({ enabled: users.developerAccessEnabled }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return rows[0]?.enabled ?? false; +} + +export async function consumeRateLimitBucket(params: { + apiKeyId: string; + windowStart: Date; +}) { + const rows = await db + .insert(developerApiRateLimitBuckets) + .values({ ...params, requestCount: 1 }) + .onConflictDoUpdate({ + target: [ + developerApiRateLimitBuckets.apiKeyId, + developerApiRateLimitBuckets.windowStart, + ], + set: { + requestCount: sql`${developerApiRateLimitBuckets.requestCount} + 1`, + }, + }) + .returning({ requestCount: developerApiRateLimitBuckets.requestCount }); + return rows[0]?.requestCount ?? 1; +} + +export async function touchApiKeyLastUsed(apiKeyId: string) { + await db + .update(developerApiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(developerApiKeys.id, apiKeyId)); +} + +export async function incrementUsageData(params: { + apiKeyId: string; + usageDate: Date; + endpoint: DeveloperApiEndpoint; +}) { + await db + .insert(developerApiUsageDaily) + .values({ + apiKeyId: params.apiKeyId, + usageDate: params.usageDate, + totalRequests: 1, + searchRequests: params.endpoint === "search" ? 1 : 0, + suggestionRequests: params.endpoint === "suggestions" ? 1 : 0, + gearRequests: params.endpoint === "gear" ? 1 : 0, + }) + .onConflictDoUpdate({ + target: [ + developerApiUsageDaily.apiKeyId, + developerApiUsageDaily.usageDate, + ], + set: { + totalRequests: sql`${developerApiUsageDaily.totalRequests} + 1`, + searchRequests: + params.endpoint === "search" + ? sql`${developerApiUsageDaily.searchRequests} + 1` + : developerApiUsageDaily.searchRequests, + suggestionRequests: + params.endpoint === "suggestions" + ? sql`${developerApiUsageDaily.suggestionRequests} + 1` + : developerApiUsageDaily.suggestionRequests, + gearRequests: + params.endpoint === "gear" + ? sql`${developerApiUsageDaily.gearRequests} + 1` + : developerApiUsageDaily.gearRequests, + }, + }); +} + +export async function getUsageForKeyIdsSince(apiKeyIds: string[], since: Date) { + if (apiKeyIds.length === 0) return []; + return db + .select() + .from(developerApiUsageDaily) + .where( + and( + inArray(developerApiUsageDaily.apiKeyId, apiKeyIds), + gte(developerApiUsageDaily.usageDate, since), + ), + ); +} + +export async function listDeveloperUsersData() { + return db + .select({ + id: users.id, + name: users.name, + email: users.email, + developerAccessEnabled: users.developerAccessEnabled, + createdAt: users.createdAt, + }) + .from(users) + .orderBy(desc(users.createdAt)); +} diff --git a/src/server/developer-api/errors.ts b/src/server/developer-api/errors.ts new file mode 100644 index 00000000..14198169 --- /dev/null +++ b/src/server/developer-api/errors.ts @@ -0,0 +1,18 @@ +export class DeveloperApiError extends Error { + constructor( + public readonly code: + | "missing_api_key" + | "invalid_api_key" + | "invalid_request" + | "not_found" + | "rate_limit_exceeded" + | "developer_access_required" + | "key_limit_reached" + | "forbidden", + public readonly status: number, + message: string, + ) { + super(message); + this.name = "DeveloperApiError"; + } +} diff --git a/src/server/developer-api/http.ts b/src/server/developer-api/http.ts new file mode 100644 index 00000000..987e602b --- /dev/null +++ b/src/server/developer-api/http.ts @@ -0,0 +1,122 @@ +if (process.env.NEXT_RUNTIME) { + import("server-only").catch(() => undefined); +} + +import { randomUUID } from "node:crypto"; +import { NextResponse } from "next/server"; +import { + DEVELOPER_API_RATE_LIMIT, + type DeveloperApiEndpoint, +} from "./constants"; +import { DeveloperApiError } from "./errors"; +import { + authenticateDeveloperApiKey, + consumeDeveloperRateLimit, + recordDeveloperApiUsage, +} from "./service"; + +type SuccessfulResponse = { + data: unknown; + pagination?: unknown; + headers?: Record; +}; + +function buildRateLimitHeaders(params?: { remaining: number; resetAt: Date }) { + if (!params) return undefined; + return { + "X-RateLimit-Limit": String(DEVELOPER_API_RATE_LIMIT), + "X-RateLimit-Remaining": String(params.remaining), + "X-RateLimit-Reset": String(Math.floor(params.resetAt.getTime() / 1000)), + }; +} + +function errorResponse(params: { + requestId: string; + error: unknown; + rateLimit?: { remaining: number; resetAt: Date }; +}) { + const known = params.error instanceof DeveloperApiError ? params.error : null; + const statusFromError = (params.error as { status?: number } | null)?.status; + const status = known?.status ?? statusFromError ?? 500; + const code = known?.code ?? (status === 404 ? "not_found" : "internal_error"); + const message = + known?.message ?? + (status === 404 + ? "The requested resource was not found." + : "An unexpected error occurred."); + + return NextResponse.json( + { error: { code, message }, meta: { requestId: params.requestId } }, + { + status, + headers: { + "X-Request-Id": params.requestId, + ...buildRateLimitHeaders(params.rateLimit), + }, + }, + ); +} + +async function recordUsageBestEffort(params: { + apiKeyId: string; + endpoint: DeveloperApiEndpoint; +}) { + try { + await recordDeveloperApiUsage(params); + } catch (error) { + console.error("Developer API usage recording failed:", error); + } +} + +export async function runDeveloperApiRequest( + request: Request, + endpoint: DeveloperApiEndpoint, + handler: () => Promise, +) { + const requestId = randomUUID(); + let credential: Awaited< + ReturnType + > | null = null; + let rateLimit: + | Awaited> + | undefined; + + try { + credential = await authenticateDeveloperApiKey( + request.headers.get("authorization"), + ); + rateLimit = await consumeDeveloperRateLimit(credential.apiKeyId); + if (!rateLimit.allowed) { + throw new DeveloperApiError( + "rate_limit_exceeded", + 429, + "Rate limit exceeded. Try again after the reset time.", + ); + } + + const response = await handler(); + await recordUsageBestEffort({ apiKeyId: credential.apiKeyId, endpoint }); + const { headers, ...body } = response; + return NextResponse.json( + { ...body, meta: { requestId } }, + { + headers: { + ...headers, + "X-Request-Id": requestId, + ...buildRateLimitHeaders(rateLimit), + }, + }, + ); + } catch (error) { + if (credential && rateLimit?.allowed) { + await recordUsageBestEffort({ + apiKeyId: credential.apiKeyId, + endpoint, + }); + } + if (!(error instanceof DeveloperApiError) || error.status >= 500) { + console.error(`Developer API ${endpoint} error:`, error); + } + return errorResponse({ requestId, error, rateLimit }); + } +} diff --git a/src/server/developer-api/schemas.ts b/src/server/developer-api/schemas.ts new file mode 100644 index 00000000..0d73ab3d --- /dev/null +++ b/src/server/developer-api/schemas.ts @@ -0,0 +1,88 @@ +import { z } from "zod"; +import { DeveloperApiError } from "./errors"; + +const querySchema = z.string().trim().min(2).max(200); + +function positiveInteger(value: string | null, fallback: number, max: number) { + if (value === null) return fallback; + if (!/^\d+$/.test(value)) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > max) return null; + return parsed; +} + +export function parseSearchParams(searchParams: URLSearchParams) { + const query = querySchema.safeParse(searchParams.get("q")); + const page = positiveInteger(searchParams.get("page"), 1, 10_000); + const limit = positiveInteger(searchParams.get("limit"), 20, 25); + + if (!query.success || page === null || limit === null) { + throw new DeveloperApiError( + "invalid_request", + 400, + "q must be 2–200 characters and page and limit must be positive integers (limit 25 or less).", + ); + } + + return { query: query.data, page, limit }; +} + +export function parseSuggestionParams(searchParams: URLSearchParams) { + const query = querySchema.safeParse(searchParams.get("q")); + const limit = positiveInteger(searchParams.get("limit"), 8, 10); + const regionValue = searchParams.get("region") ?? "GLOBAL"; + const region = z.enum(["GLOBAL", "EU", "JP"]).safeParse(regionValue); + + if (!query.success || limit === null || !region.success) { + throw new DeveloperApiError( + "invalid_request", + 400, + "q must be 2–200 characters, limit must be a positive integer, and region must be GLOBAL, EU, or JP.", + ); + } + + return { query: query.data, limit, region: region.data }; +} + +export function parseSpecSelectors(searchParams: URLSearchParams) { + const value = searchParams.get("fields"); + if (!value || value.length > 2_000) { + throw new DeveloperApiError( + "invalid_request", + 400, + "fields must contain one to 50 comma-separated field or category selectors.", + ); + } + + const selectors = value + .split(",") + .map((selector) => selector.trim()) + .filter(Boolean); + const validSelector = /^[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9-]*)*$/; + + if ( + selectors.length === 0 || + selectors.length > 50 || + selectors.some((selector) => !validSelector.test(selector)) + ) { + throw new DeveloperApiError( + "invalid_request", + 400, + "fields must contain one to 50 comma-separated field or category selectors.", + ); + } + + return selectors; +} + +export function parseKeyName(value: unknown) { + const result = z.string().trim().min(1).max(100).safeParse(value); + if (!result.success) { + throw new DeveloperApiError( + "invalid_request", + 400, + "A key name between 1 and 100 characters is required.", + ); + } + return result.data; +} diff --git a/src/server/developer-api/serializers.ts b/src/server/developer-api/serializers.ts new file mode 100644 index 00000000..e8f44c71 --- /dev/null +++ b/src/server/developer-api/serializers.ts @@ -0,0 +1,165 @@ +if (process.env.NEXT_RUNTIME) { + import("server-only").catch(() => undefined); +} + +import type { SearchResponse } from "~/types/search-results"; +import type { Suggestion } from "~/types/search"; +import type { GearItem } from "~/types/gear"; + +type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +const DEVELOPER_API_GEAR_METADATA_KEYS = new Set([ + "id", + "createdAt", + "updatedAt", +]); + +function serializeJsonWithOmittedKeys( + value: unknown, + omittedKeys?: ReadonlySet, +): JsonValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (value instanceof Date) return value.toISOString(); + if (Array.isArray(value)) { + return value.map((item) => serializeJsonWithOmittedKeys(item, omittedKeys)); + } + if (typeof value === "object") { + const serialized: Record = {}; + for (const [key, nestedValue] of Object.entries(value)) { + if (nestedValue === undefined || omittedKeys?.has(key)) continue; + serialized[key] = serializeJsonWithOmittedKeys(nestedValue, omittedKeys); + } + return serialized; + } + if (typeof value === "bigint") return value.toString(); + return null; +} + +/** Convert database values to a JSON-only public API representation. */ +export function serializeJson(value: unknown): JsonValue { + return serializeJsonWithOmittedKeys(value); +} + +export function serializeSearchResponse(result: SearchResponse) { + return { + data: result.results.map((item) => ({ + slug: item.slug, + name: item.name, + brandName: item.brandName, + gearType: item.gearType, + thumbnailUrl: item.thumbnailUrl, + releaseDate: item.releaseDate ? serializeJson(item.releaseDate) : null, + releaseDatePrecision: item.releaseDatePrecision ?? null, + announcedDate: item.announcedDate + ? serializeJson(item.announcedDate) + : null, + announceDatePrecision: item.announceDatePrecision ?? null, + msrpNowUsdCents: item.msrpNowUsdCents ?? null, + msrpAtLaunchUsdCents: item.msrpAtLaunchUsdCents ?? null, + mpbMaxPriceUsdCents: item.mpbMaxPriceUsdCents ?? null, + regionalAliases: serializeJson(item.regionalAliases ?? []), + })), + pagination: { + page: result.page, + limit: result.pageSize, + total: result.total ?? 0, + totalPages: result.totalPages ?? 0, + }, + }; +} + +export function serializeSuggestions(suggestions: Suggestion[]) { + const data: Array< + | { kind: "brand"; slug: string; name: string } + | { + kind: "gear"; + slug: string; + name: string; + canonicalName: string; + matchedName: string; + matchSource: string; + isBestMatch: boolean; + brandName: string | null; + gearType: string; + } + > = []; + + for (const suggestion of suggestions) { + if (suggestion.kind === "smart-action") continue; + if (suggestion.kind === "brand") { + data.push({ + kind: "brand", + slug: suggestion.href.replace("/brand/", ""), + name: suggestion.brandName, + }); + continue; + } + + data.push({ + kind: "gear", + slug: suggestion.href.replace("/gear/", ""), + name: suggestion.localizedName, + canonicalName: suggestion.canonicalName, + matchedName: suggestion.matchedName, + matchSource: suggestion.matchSource, + isBestMatch: suggestion.isBestMatch, + brandName: suggestion.brandName, + gearType: suggestion.gearType, + }); + } + + return { + data, + }; +} + +export function serializeGear(item: GearItem) { + const publicGear: Record = { ...item }; + // These relation fields are not part of the current beta contract. + delete publicGear.brandId; + delete publicGear.mountId; + delete publicGear.searchName; + // Full video-mode matrices are intentionally not part of the beta contract. + // Consumers can still use the published video capability specs selectively. + delete publicGear.videoModes; + if (item.rawSamples) { + publicGear.rawSamples = item.rawSamples.map((sample) => { + const publicSample: Record = { ...sample }; + delete publicSample.uploadedByUserId; + delete publicSample.isDeleted; + delete publicSample.deletedAt; + return publicSample; + }); + } + return { + data: serializeJsonWithOmittedKeys( + publicGear, + DEVELOPER_API_GEAR_METADATA_KEYS, + ), + }; +} + +export function serializeDeveloperApiSpecs( + specs: Array<{ id: string; raw: unknown; display: string }>, +) { + return { + data: specs.map((spec) => ({ + id: spec.id, + raw: serializeJson(spec.raw), + display: spec.display, + })), + }; +} diff --git a/src/server/developer-api/service.ts b/src/server/developer-api/service.ts new file mode 100644 index 00000000..2209a581 --- /dev/null +++ b/src/server/developer-api/service.ts @@ -0,0 +1,361 @@ +import "server-only"; + +import { createHash, randomBytes } from "node:crypto"; +import { requireRole } from "~/lib/auth/auth-helpers"; +import { getSessionOrThrow } from "~/server/auth"; +import { fetchGearBySlug } from "~/server/gear/service"; +import { getSuggestions, searchGear } from "~/server/search/service"; +import type { GearRegion } from "~/types/gear"; +import { + DEVELOPER_API_KEY_DISPLAY_LENGTH, + DEVELOPER_API_KEY_PREFIX, + DEVELOPER_API_MAX_ACTIVE_KEYS, + DEVELOPER_API_RATE_LIMIT, + DEVELOPER_API_RATE_LIMIT_WINDOW_MS, + type DeveloperApiEndpoint, +} from "./constants"; +import { + consumeRateLimitBucket, + createApiKeyWithinActiveLimitData, + findUsableApiKeyByHash, + getDeveloperAccessData, + getUsageForKeyIdsSince, + incrementUsageData, + listApiKeysForUser, + listAllApiKeysData, + listDeveloperUsersData, + revokeAllApiKeysForUser, + revokeApiKeyData, + setDeveloperAccessData, + touchApiKeyLastUsed, +} from "./data"; +import { DeveloperApiError } from "./errors"; +import { parseKeyName } from "./schemas"; + +export type DeveloperApiCredential = { + apiKeyId: string; + userId: string; + keyPrefix: string; +}; + +function utcDay(date: Date) { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()), + ); +} + +function utcMinute(date: Date) { + return new Date( + Math.floor(date.getTime() / DEVELOPER_API_RATE_LIMIT_WINDOW_MS) * + DEVELOPER_API_RATE_LIMIT_WINDOW_MS, + ); +} + +function hashApiKey(key: string) { + return createHash("sha256").update(key).digest("hex"); +} + +function newApiKey() { + const secret = randomBytes(32).toString("base64url"); + const value = `${DEVELOPER_API_KEY_PREFIX}${secret}`; + return { + value, + keyHash: hashApiKey(value), + keyPrefix: value.slice(0, DEVELOPER_API_KEY_DISPLAY_LENGTH), + }; +} + +export function getBearerApiKey(authorization: string | null) { + if (!authorization) { + throw new DeveloperApiError( + "missing_api_key", + 401, + "An API key is required.", + ); + } + const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()); + const apiKey = match?.[1]; + if (!apiKey?.startsWith(DEVELOPER_API_KEY_PREFIX)) { + throw new DeveloperApiError( + "invalid_api_key", + 401, + "The supplied API key is invalid.", + ); + } + return apiKey; +} + +export async function authenticateDeveloperApiKey( + authorization: string | null, +) { + const value = getBearerApiKey(authorization); + const result = await findUsableApiKeyByHash(hashApiKey(value)); + if (!result?.developerAccessEnabled) { + throw new DeveloperApiError( + "invalid_api_key", + 401, + "The supplied API key is invalid.", + ); + } + return { + apiKeyId: result.key.id, + userId: result.key.userId, + keyPrefix: result.key.keyPrefix, + } satisfies DeveloperApiCredential; +} + +export async function consumeDeveloperRateLimit( + apiKeyId: string, + now = new Date(), +) { + const windowStart = utcMinute(now); + const requestCount = await consumeRateLimitBucket({ apiKeyId, windowStart }); + const resetAt = new Date( + windowStart.getTime() + DEVELOPER_API_RATE_LIMIT_WINDOW_MS, + ); + return { + allowed: requestCount <= DEVELOPER_API_RATE_LIMIT, + remaining: Math.max(0, DEVELOPER_API_RATE_LIMIT - requestCount), + resetAt, + }; +} + +export async function recordDeveloperApiUsage(params: { + apiKeyId: string; + endpoint: DeveloperApiEndpoint; + now?: Date; +}) { + const now = params.now ?? new Date(); + await Promise.all([ + incrementUsageData({ + apiKeyId: params.apiKeyId, + endpoint: params.endpoint, + usageDate: utcDay(now), + }), + touchApiKeyLastUsed(params.apiKeyId), + ]); +} + +export async function requireDeveloperPortalUser() { + const session = await getSessionOrThrow(); + const enabled = await getDeveloperAccessData(session.user.id); + if (!enabled) { + throw new DeveloperApiError( + "developer_access_required", + 403, + "Developer API access has not been enabled for this account.", + ); + } + return session.user; +} + +function summarizeUsage( + usage: Awaited>, + keyId: string, + today: Date, +) { + return usage.reduce( + (summary, row) => { + if (row.apiKeyId !== keyId) return summary; + summary.last30Days += row.totalRequests; + if (row.usageDate.getTime() === today.getTime()) + summary.today += row.totalRequests; + return summary; + }, + { today: 0, last30Days: 0 }, + ); +} + +export async function getDeveloperPortalData() { + const user = await requireDeveloperPortalUser(); + const keys = (await listApiKeysForUser(user.id)).filter( + (key) => key.revokedAt === null, + ); + const since = utcDay(new Date(Date.now() - 29 * 24 * 60 * 60 * 1000)); + const usage = await getUsageForKeyIdsSince( + keys.map((key) => key.id), + since, + ); + const today = utcDay(new Date()); + return { + userName: user.name ?? user.email, + keyLimit: DEVELOPER_API_MAX_ACTIVE_KEYS, + rateLimit: DEVELOPER_API_RATE_LIMIT, + keys: keys.map((key) => ({ + id: key.id, + name: key.name, + keyPrefix: key.keyPrefix, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + usage: summarizeUsage(usage, key.id, today), + })), + }; +} + +export async function createDeveloperApiKey(input: { + name: unknown; + userId?: string; +}) { + const sessionUser = await requireDeveloperPortalUser(); + const userId = input.userId ?? sessionUser.id; + if (userId !== sessionUser.id) { + throw new DeveloperApiError( + "forbidden", + 403, + "You cannot create a key for another user.", + ); + } + const name = parseKeyName(input.name); + return createDeveloperApiKeyForUser({ + userId, + name, + limitErrorMessage: "You can have up to three active API keys.", + }); +} + +export async function revokeDeveloperApiKey(keyId: string) { + const user = await requireDeveloperPortalUser(); + const key = await revokeApiKeyData({ + keyId, + userId: user.id, + revokedByUserId: user.id, + }); + if (!key) throw new DeveloperApiError("not_found", 404, "API key not found."); +} + +async function requireDeveloperAdmin() { + const session = await getSessionOrThrow(); + if (!requireRole(session.user, ["ADMIN"])) { + throw new DeveloperApiError( + "forbidden", + 403, + "Administrator access is required.", + ); + } + return session.user; +} + +export async function getDeveloperAdminData() { + await requireDeveloperAdmin(); + const [users, keys] = await Promise.all([ + listDeveloperUsersData(), + listAllApiKeysData(), + ]); + const usage = await getUsageForKeyIdsSince( + keys.map((key) => key.id), + utcDay(new Date(Date.now() - 29 * 24 * 60 * 60 * 1000)), + ); + const today = utcDay(new Date()); + + return { + users: users.map((user) => { + const userKeys = keys.filter((key) => key.userId === user.id); + return { + ...user, + activeKeyCount: userKeys.filter((key) => key.revokedAt === null).length, + usage: userKeys.reduce( + (summary, key) => { + const keyUsage = summarizeUsage(usage, key.id, today); + return { + today: summary.today + keyUsage.today, + last30Days: summary.last30Days + keyUsage.last30Days, + }; + }, + { today: 0, last30Days: 0 }, + ), + }; + }), + keys: keys.map((key) => ({ + id: key.id, + userId: key.userId, + name: key.name, + keyPrefix: key.keyPrefix, + createdAt: key.createdAt, + lastUsedAt: key.lastUsedAt, + revokedAt: key.revokedAt, + })), + }; +} + +export async function setDeveloperAccessForUser( + userId: string, + enabled: boolean, +) { + const admin = await requireDeveloperAdmin(); + const user = await setDeveloperAccessData(userId, enabled); + if (!user) throw new DeveloperApiError("not_found", 404, "User not found."); + if (!enabled) await revokeAllApiKeysForUser(userId, admin.id); +} + +export async function createDeveloperApiKeyForAdmin(params: { + userId: string; + name: unknown; +}) { + await requireDeveloperAdmin(); + const enabled = await getDeveloperAccessData(params.userId); + if (!enabled) { + throw new DeveloperApiError( + "developer_access_required", + 403, + "Grant developer access before creating a key for this user.", + ); + } + const name = parseKeyName(params.name); + return createDeveloperApiKeyForUser({ + userId: params.userId, + name, + limitErrorMessage: "This user already has three active API keys.", + }); +} + +async function createDeveloperApiKeyForUser(params: { + userId: string; + name: string; + limitErrorMessage: string; +}) { + const secret = newApiKey(); + const key = await createApiKeyWithinActiveLimitData( + { userId: params.userId, name: params.name, ...secret }, + DEVELOPER_API_MAX_ACTIVE_KEYS, + ); + if (!key) { + throw new DeveloperApiError( + "key_limit_reached", + 409, + params.limitErrorMessage, + ); + } + return { key, secret: secret.value }; +} + +export async function revokeDeveloperApiKeyForAdmin(keyId: string) { + const admin = await requireDeveloperAdmin(); + const key = await revokeApiKeyData({ keyId, revokedByUserId: admin.id }); + if (!key) throw new DeveloperApiError("not_found", 404, "API key not found."); +} + +export async function searchDeveloperApi( + query: string, + page: number, + limit: number, +) { + return searchGear({ + query, + page, + pageSize: limit, + sort: "relevance", + includeTotal: true, + }); +} + +export async function getDeveloperSuggestions( + query: string, + limit: number, + region: GearRegion, +) { + return getSuggestions(query, limit, region); +} + +export async function getDeveloperGear(slug: string) { + return fetchGearBySlug(slug); +} diff --git a/src/server/developer-api/specs.ts b/src/server/developer-api/specs.ts new file mode 100644 index 00000000..a9ab0d54 --- /dev/null +++ b/src/server/developer-api/specs.ts @@ -0,0 +1,63 @@ +import "server-only"; + +import { + getDeveloperApiSpecCatalog, + getDeveloperApiSpecFields, + getDeveloperApiSpecValue, +} from "~/lib/specs/registry"; +import { DeveloperApiError } from "./errors"; +import { getDeveloperGear } from "./service"; + +export function getDeveloperApiSpecsCatalog() { + return getDeveloperApiSpecCatalog(); +} + +export function resolveDeveloperApiSpecSelectors(selectors: string[]) { + const fields = getDeveloperApiSpecFields(); + const fieldsById = new Map( + fields.map((definition) => [definition.field.api.id, definition]), + ); + const fieldsByCategory = new Map(); + + for (const definition of fields) { + const categoryFields = + fieldsByCategory.get(definition.field.api.category) ?? []; + categoryFields.push(definition); + fieldsByCategory.set(definition.field.api.category, categoryFields); + } + + const resolved = [] as typeof fields; + const seen = new Set(); + for (const selector of selectors) { + const selected = fieldsById.get(selector) + ? [fieldsById.get(selector)!] + : fieldsByCategory.get(selector); + if (!selected) { + throw new DeveloperApiError( + "invalid_request", + 400, + `Unknown spec selector: ${selector}.`, + ); + } + for (const definition of selected) { + if (seen.has(definition.field.api.id)) continue; + seen.add(definition.field.api.id); + resolved.push(definition); + } + } + return resolved; +} + +export async function getDeveloperGearSelectedSpecs(params: { + slug: string; + selectors: string[]; +}) { + const fields = resolveDeveloperApiSpecSelectors(params.selectors); + const gear = await getDeveloperGear(params.slug); + + return fields + .map((definition) => getDeveloperApiSpecValue(gear, definition)) + .filter((value): value is { id: string; raw: unknown; display: string } => + Boolean(value), + ); +} diff --git a/src/server/gear/data.ts b/src/server/gear/data.ts index 0fe1ca3c..88a66445 100644 --- a/src/server/gear/data.ts +++ b/src/server/gear/data.ts @@ -350,6 +350,7 @@ export async function fetchGearBySlug(slug: string): Promise { const base: GearItem = { ...gearItem[0]!.gear, + brands: gearItem[0]!.brands ?? null, cameraSpecs: null, analogCameraSpecs: null, lensSpecs: null, diff --git a/tests/playwright/developer-api.spec.ts b/tests/playwright/developer-api.spec.ts new file mode 100644 index 00000000..fa02ae70 --- /dev/null +++ b/tests/playwright/developer-api.spec.ts @@ -0,0 +1,33 @@ +import { expect, test } from "@playwright/test"; + +test.describe("developer API", () => { + test("rejects an unauthenticated public request with the documented error shape", async ({ + request, + }) => { + const response = await request.get("/api/v1/search?q=nikon"); + + expect(response.status()).toBe(401); + expect(response.headers()["x-request-id"]).toBeTruthy(); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "missing_api_key" }, + meta: { requestId: expect.any(String) }, + }); + }); + + test("protects both live spec endpoints with the same API-key gate", async ({ + request, + }) => { + const [catalog, selected] = await Promise.all([ + request.get("/api/v1/specs"), + request.get("/api/v1/gear/nikon-z6-iii/specs?fields=camera.sensor"), + ]); + + for (const response of [catalog, selected]) { + expect(response.status()).toBe(401); + expect(response.headers()["x-request-id"]).toBeTruthy(); + await expect(response.json()).resolves.toMatchObject({ + error: { code: "missing_api_key" }, + }); + } + }); +}); diff --git a/tests/unit/botid-protected-routes.test.ts b/tests/unit/botid-protected-routes.test.ts new file mode 100644 index 00000000..5cbb1231 --- /dev/null +++ b/tests/unit/botid-protected-routes.test.ts @@ -0,0 +1,15 @@ +import { describe,expect,it } from "vitest"; +import { botIdProtectedRoutes } from "~/lib/security/botid-protected-routes"; + +describe("BotID protected routes", () => { + it("protects gear edit server action posts from gear and under-construction pages", () => { + expect(botIdProtectedRoutes).toEqual( + expect.arrayContaining([ + { path: "/gear/*", method: "POST" }, + { path: "/*/gear/*", method: "POST" }, + { path: "/lists/under-construction", method: "POST" }, + { path: "/*/lists/under-construction", method: "POST" }, + ]), + ); + }); +}); diff --git a/tests/unit/developer-api-actions.test.ts b/tests/unit/developer-api-actions.test.ts new file mode 100644 index 00000000..605078aa --- /dev/null +++ b/tests/unit/developer-api-actions.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeveloperApiError } from "~/server/developer-api/errors"; + +const mocks = vi.hoisted(() => ({ + createDeveloperApiKey: vi.fn(), + revalidatePath: vi.fn(), +})); + +vi.mock("next/cache", () => ({ revalidatePath: mocks.revalidatePath })); +vi.mock("~/server/developer-api/service", () => ({ + createDeveloperApiKey: mocks.createDeveloperApiKey, + createDeveloperApiKeyForAdmin: vi.fn(), + revokeDeveloperApiKey: vi.fn(), + revokeDeveloperApiKeyForAdmin: vi.fn(), + setDeveloperAccessForUser: vi.fn(), +})); + +import { actionCreateDeveloperApiKey } from "~/server/developer-api/actions"; + +describe("developer API actions", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("preserves expected DeveloperApiError details", async () => { + mocks.createDeveloperApiKey.mockRejectedValue( + new DeveloperApiError( + "key_limit_reached", + 409, + "You can have up to three active API keys.", + ), + ); + + const formData = new FormData(); + formData.set("name", "Production"); + + await expect(actionCreateDeveloperApiKey(formData)).resolves.toEqual({ + ok: false, + code: "key_limit_reached", + message: "You can have up to three active API keys.", + }); + }); + + it("returns a safe failure for unexpected errors", async () => { + mocks.createDeveloperApiKey.mockRejectedValue(new Error("Database down")); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + + await expect(actionCreateDeveloperApiKey(new FormData())).resolves.toEqual({ + ok: false, + code: undefined, + message: undefined, + }); + expect(consoleError).toHaveBeenCalledWith( + "Developer API action failed:", + expect.any(Error), + ); + }); +}); diff --git a/tests/unit/developer-api-http.test.ts b/tests/unit/developer-api-http.test.ts new file mode 100644 index 00000000..399805b0 --- /dev/null +++ b/tests/unit/developer-api-http.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeveloperApiError } from "~/server/developer-api/errors"; + +const serviceMocks = vi.hoisted(() => ({ + authenticateDeveloperApiKey: vi.fn(), + consumeDeveloperRateLimit: vi.fn(), + recordDeveloperApiUsage: vi.fn(), +})); + +vi.mock("~/server/developer-api/service", () => serviceMocks); + +import { runDeveloperApiRequest } from "~/server/developer-api/http"; + +describe("developer API HTTP wrapper", () => { + beforeEach(() => { + vi.clearAllMocks(); + serviceMocks.authenticateDeveloperApiKey.mockResolvedValue({ + apiKeyId: "key-1", + }); + serviceMocks.consumeDeveloperRateLimit.mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: new Date("2025-01-01T00:01:00.000Z"), + }); + }); + + it("adds request and rate-limit metadata to successful responses", async () => { + const response = await runDeveloperApiRequest( + new Request("https://sharply.test/api/v1/search", { + headers: { authorization: "Bearer sharply_live_test" }, + }), + "search", + async () => ({ data: [] }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("X-RateLimit-Remaining")).toBe("59"); + expect((await response.json()).meta.requestId).toBeTruthy(); + expect(serviceMocks.recordDeveloperApiUsage).toHaveBeenCalledWith({ + apiKeyId: "key-1", + endpoint: "search", + }); + }); + + it("returns a successful response when usage recording fails", async () => { + const logError = vi.spyOn(console, "error").mockImplementation(() => {}); + serviceMocks.recordDeveloperApiUsage.mockRejectedValueOnce( + new Error("usage database unavailable"), + ); + + const response = await runDeveloperApiRequest( + new Request("https://sharply.test/api/v1/search"), + "search", + async () => ({ data: [] }), + ); + + expect(response.status).toBe(200); + expect(serviceMocks.recordDeveloperApiUsage).toHaveBeenCalledTimes(1); + expect(logError).toHaveBeenCalledWith( + "Developer API usage recording failed:", + expect.any(Error), + ); + await expect(response.json()).resolves.toMatchObject({ data: [] }); + logError.mockRestore(); + }); + + it("returns a consistent 429 without recording usage", async () => { + serviceMocks.consumeDeveloperRateLimit.mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date("2025-01-01T00:01:00.000Z"), + }); + + const response = await runDeveloperApiRequest( + new Request("https://sharply.test/api/v1/search"), + "search", + async () => ({ data: [] }), + ); + + expect(response.status).toBe(429); + expect((await response.json()).error.code).toBe("rate_limit_exceeded"); + expect(serviceMocks.recordDeveloperApiUsage).not.toHaveBeenCalled(); + }); + + it("preserves public input errors and records authenticated requests", async () => { + const response = await runDeveloperApiRequest( + new Request("https://sharply.test/api/v1/search"), + "search", + async () => { + throw new DeveloperApiError("invalid_request", 400, "Invalid query"); + }, + ); + + expect(response.status).toBe(400); + expect((await response.json()).error.code).toBe("invalid_request"); + expect(serviceMocks.recordDeveloperApiUsage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/developer-api-portal-service.test.ts b/tests/unit/developer-api-portal-service.test.ts new file mode 100644 index 00000000..55423802 --- /dev/null +++ b/tests/unit/developer-api-portal-service.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSessionOrThrow: vi.fn(), + requireRole: vi.fn(), + fetchGearBySlug: vi.fn(), + getSuggestions: vi.fn(), + searchGear: vi.fn(), + consumeRateLimitBucket: vi.fn(), + countActiveApiKeysForUser: vi.fn(), + createApiKeyData: vi.fn(), + createApiKeyWithinActiveLimitData: vi.fn(), + findUsableApiKeyByHash: vi.fn(), + getDeveloperAccessData: vi.fn(), + getUsageForKeyIdsSince: vi.fn(), + incrementUsageData: vi.fn(), + listApiKeysForUser: vi.fn(), + listAllApiKeysData: vi.fn(), + listDeveloperUsersData: vi.fn(), + revokeAllApiKeysForUser: vi.fn(), + revokeApiKeyData: vi.fn(), + setDeveloperAccessData: vi.fn(), + touchApiKeyLastUsed: vi.fn(), +})); + +vi.mock("server-only", () => ({})); +vi.mock("~/lib/auth/auth-helpers", () => ({ requireRole: mocks.requireRole })); +vi.mock("~/server/auth", () => ({ + getSessionOrThrow: mocks.getSessionOrThrow, +})); +vi.mock("~/server/gear/service", () => ({ + fetchGearBySlug: mocks.fetchGearBySlug, +})); +vi.mock("~/server/search/service", () => ({ + getSuggestions: mocks.getSuggestions, + searchGear: mocks.searchGear, +})); +vi.mock("~/server/developer-api/data", () => ({ + consumeRateLimitBucket: mocks.consumeRateLimitBucket, + countActiveApiKeysForUser: mocks.countActiveApiKeysForUser, + createApiKeyData: mocks.createApiKeyData, + createApiKeyWithinActiveLimitData: mocks.createApiKeyWithinActiveLimitData, + findUsableApiKeyByHash: mocks.findUsableApiKeyByHash, + getDeveloperAccessData: mocks.getDeveloperAccessData, + getUsageForKeyIdsSince: mocks.getUsageForKeyIdsSince, + incrementUsageData: mocks.incrementUsageData, + listApiKeysForUser: mocks.listApiKeysForUser, + listAllApiKeysData: mocks.listAllApiKeysData, + listDeveloperUsersData: mocks.listDeveloperUsersData, + revokeAllApiKeysForUser: mocks.revokeAllApiKeysForUser, + revokeApiKeyData: mocks.revokeApiKeyData, + setDeveloperAccessData: mocks.setDeveloperAccessData, + touchApiKeyLastUsed: mocks.touchApiKeyLastUsed, +})); + +import { + createDeveloperApiKey, + getDeveloperPortalData, +} from "~/server/developer-api/service"; + +describe("getDeveloperPortalData", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSessionOrThrow.mockResolvedValue({ + user: { + id: "user-1", + name: "Tester", + email: "tester@example.com", + }, + }); + mocks.getDeveloperAccessData.mockResolvedValue(true); + mocks.listApiKeysForUser.mockResolvedValue([ + { + id: "active-key", + name: "Active key", + keyPrefix: "sharply_live_active", + createdAt: new Date("2026-07-01T00:00:00.000Z"), + lastUsedAt: null, + revokedAt: null, + }, + { + id: "revoked-key", + name: "Revoked key", + keyPrefix: "sharply_live_revoked", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + lastUsedAt: null, + revokedAt: new Date("2026-07-02T00:00:00.000Z"), + }, + ]); + mocks.getUsageForKeyIdsSince.mockResolvedValue([]); + }); + + it("returns only active keys to the developer portal", async () => { + const portal = await getDeveloperPortalData(); + + expect(portal.keys).toEqual([ + expect.objectContaining({ id: "active-key", name: "Active key" }), + ]); + expect(mocks.getUsageForKeyIdsSince).toHaveBeenCalledWith( + ["active-key"], + expect.any(Date), + ); + }); + + it("creates a key through the atomic active-key limit data operation", async () => { + mocks.createApiKeyWithinActiveLimitData.mockResolvedValue({ id: "key-1" }); + + const result = await createDeveloperApiKey({ name: "Production" }); + + expect(result.key).toEqual({ id: "key-1" }); + expect(result.secret).toMatch(/^sharply_live_/); + expect(mocks.createApiKeyWithinActiveLimitData).toHaveBeenCalledWith( + expect.objectContaining({ userId: "user-1", name: "Production" }), + 3, + ); + }); + + it("reports the active-key limit when the atomic creation operation declines", async () => { + mocks.createApiKeyWithinActiveLimitData.mockResolvedValue(null); + + await expect( + createDeveloperApiKey({ name: "Production" }), + ).rejects.toMatchObject({ + code: "key_limit_reached", + status: 409, + }); + }); +}); diff --git a/tests/unit/developer-api-schemas-serializers.test.ts b/tests/unit/developer-api-schemas-serializers.test.ts new file mode 100644 index 00000000..431b5628 --- /dev/null +++ b/tests/unit/developer-api-schemas-serializers.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from "vitest"; +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { + serializeDeveloperApiSpecs, + serializeGear, + serializeSuggestions, +} from "~/server/developer-api/serializers"; +import { + parseSearchParams, + parseSpecSelectors, + parseSuggestionParams, +} from "~/server/developer-api/schemas"; + +describe("developer API request schemas", () => { + it("uses bounded defaults for search", () => { + expect(parseSearchParams(new URLSearchParams("q=Nikon+Z6"))).toEqual({ + query: "Nikon Z6", + page: 1, + limit: 20, + }); + }); + + it("rejects invalid public query values", () => { + expect(() => + parseSearchParams(new URLSearchParams("q=x&limit=26")), + ).toThrow(DeveloperApiError); + expect(() => + parseSuggestionParams(new URLSearchParams("q=Z6®ion=US")), + ).toThrow(DeveloperApiError); + expect(() => parseSpecSelectors(new URLSearchParams())).toThrow( + DeveloperApiError, + ); + }); + + it("parses bounded spec field and category selectors", () => { + expect( + parseSpecSelectors( + new URLSearchParams("fields=camera.sensor,camera.sensor.isoRange"), + ), + ).toEqual(["camera.sensor", "camera.sensor.isoRange"]); + }); +}); + +describe("developer API serializers", () => { + it("removes website-only smart actions from suggestions", () => { + const response = serializeSuggestions([ + { + id: "gear:1", + kind: "camera", + type: "gear", + href: "/gear/nikon-z6-iii", + title: "Nikon Z6 III", + label: "Nikon Z6 III", + gearId: "1", + brandName: "Nikon", + canonicalName: "Nikon Z6 III", + localizedName: "Nikon Z6 III", + matchedName: "Nikon Z6 III", + matchSource: "canonical", + isBestMatch: true, + gearType: "CAMERA", + }, + { + id: "compare:1", + kind: "smart-action", + type: "smart-action", + href: "/compare", + title: "Compare", + label: "Compare", + action: "compare", + compareSlugs: ["a", "b"], + compareTitles: ["A", "B"], + }, + ]); + + expect(response.data).toEqual([ + expect.objectContaining({ + kind: "gear", + slug: "nikon-z6-iii", + isBestMatch: true, + }), + ]); + }); + + it("serializes full gear data while excluding database implementation fields", () => { + const response = serializeGear({ + id: "internal-id", + brandId: "brand-id", + mountId: "legacy-mount", + searchName: "nikon z6", + slug: "nikon-z6", + name: "Nikon Z6", + gearType: "CAMERA", + publicationState: "PUBLISHED", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + brands: { + id: "brand-id", + name: "Nikon", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + cameraSpecs: { + gearId: "internal-id", + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + cameraCardSlots: [ + { + id: "card-slot-id", + gearId: "internal-id", + slotIndex: 1, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-02T00:00:00.000Z"), + }, + ], + videoModes: [ + { + id: "video-mode-1", + gearId: "internal-id", + resolutionLabel: "4K", + fps: 60, + }, + ], + rawSamples: [ + { + id: "sample-1", + fileUrl: "https://example.test/sample.nef", + uploadedByUserId: "user-1", + isDeleted: false, + deletedAt: null, + }, + ], + } as never); + + expect(response.data).toMatchObject({ + slug: "nikon-z6", + brands: { name: "Nikon" }, + cameraSpecs: { gearId: "internal-id" }, + cameraCardSlots: [{ gearId: "internal-id", slotIndex: 1 }], + }); + expect(response.data).not.toHaveProperty("id"); + expect(response.data).not.toHaveProperty("createdAt"); + expect(response.data).not.toHaveProperty("updatedAt"); + expect(response.data).not.toHaveProperty("searchName"); + expect(response.data).not.toHaveProperty("videoModes"); + expect(response.data).not.toHaveProperty("brands.id"); + expect(response.data).not.toHaveProperty("brands.createdAt"); + expect(response.data).not.toHaveProperty("cameraSpecs.createdAt"); + expect(response.data).not.toHaveProperty("cameraCardSlots.0.id"); + expect(response.data).not.toHaveProperty("cameraCardSlots.0.updatedAt"); + expect(response.data).not.toHaveProperty("rawSamples.0.uploadedByUserId"); + }); + + it("serializes selected specs with structured raw values", () => { + expect( + serializeDeveloperApiSpecs([ + { + id: "camera.sensor.isoRange", + raw: { min: 100, max: 51200 }, + display: "ISO 100 - 51200", + }, + ]), + ).toEqual({ + data: [ + { + id: "camera.sensor.isoRange", + raw: { min: 100, max: 51200 }, + display: "ISO 100 - 51200", + }, + ], + }); + }); +}); diff --git a/tests/unit/developer-api-spec-routes.test.ts b/tests/unit/developer-api-spec-routes.test.ts new file mode 100644 index 00000000..3a0b77fe --- /dev/null +++ b/tests/unit/developer-api-spec-routes.test.ts @@ -0,0 +1,91 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getCatalog: vi.fn(), + getSelectedSpecs: vi.fn(), + parseSpecSelectors: vi.fn(), + serializeSpecs: vi.fn(), + runRequest: vi.fn( + async ( + _request: Request, + _endpoint: string, + handler: () => Promise, + ) => handler(), + ), +})); + +vi.mock("~/server/developer-api/http", () => ({ + runDeveloperApiRequest: mocks.runRequest, +})); +vi.mock("~/server/developer-api/specs", () => ({ + getDeveloperApiSpecsCatalog: mocks.getCatalog, + getDeveloperGearSelectedSpecs: mocks.getSelectedSpecs, +})); +vi.mock("~/server/developer-api/schemas", () => ({ + parseSpecSelectors: mocks.parseSpecSelectors, +})); +vi.mock("~/server/developer-api/serializers", () => ({ + serializeDeveloperApiSpecs: mocks.serializeSpecs, +})); + +import { GET as getSpecs } from "~/app/api/v1/specs/route"; +import { GET as getGearSpecs } from "~/app/api/v1/gear/[slug]/specs/route"; + +describe("developer API spec routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getCatalog.mockReturnValue([{ id: "camera.sensor", fields: [] }]); + mocks.parseSpecSelectors.mockReturnValue(["camera.sensor"]); + mocks.getSelectedSpecs.mockResolvedValue([ + { + id: "camera.sensor.isoRange", + raw: { min: 100, max: 51200 }, + display: "ISO 100 - 51200", + }, + ]); + mocks.serializeSpecs.mockReturnValue({ + data: [{ id: "camera.sensor.isoRange" }], + }); + }); + + it("returns the live catalog through existing gear usage protection", async () => { + await expect( + getSpecs(new Request("https://sharply.test/api/v1/specs")), + ).resolves.toEqual({ + data: { categories: [{ id: "camera.sensor", fields: [] }] }, + headers: { "Cache-Control": "no-store" }, + }); + expect(mocks.runRequest).toHaveBeenCalledWith( + expect.any(Request), + "gear", + expect.any(Function), + ); + }); + + it("validates a gear slug and forwards parsed selectors to the projection", async () => { + await expect( + getGearSpecs( + new Request( + "https://sharply.test/api/v1/gear/nikon-z6-iii/specs?fields=camera.sensor", + ), + { params: Promise.resolve({ slug: "nikon-z6-iii" }) }, + ), + ).resolves.toEqual({ + data: [{ id: "camera.sensor.isoRange" }], + headers: { "Cache-Control": "no-store" }, + }); + expect(mocks.getSelectedSpecs).toHaveBeenCalledWith({ + slug: "nikon-z6-iii", + selectors: ["camera.sensor"], + }); + }); + + it("rejects an invalid gear slug before selecting specs", async () => { + await expect( + getGearSpecs(new Request("https://sharply.test/api/v1/gear//specs"), { + params: Promise.resolve({ slug: "" }), + }), + ).rejects.toMatchObject({ code: "invalid_request", status: 400 }); + expect(mocks.getSelectedSpecs).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/developer-api-specs.test.ts b/tests/unit/developer-api-specs.test.ts new file mode 100644 index 00000000..44d11202 --- /dev/null +++ b/tests/unit/developer-api-specs.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("~/server/developer-api/service", () => ({ + getDeveloperGear: vi.fn(), +})); + +import { + getDeveloperApiSpecCatalog, + getDeveloperApiSpecFields, + getDeveloperApiSpecValue, +} from "~/lib/specs/registry"; +import { DeveloperApiError } from "~/server/developer-api/errors"; +import { resolveDeveloperApiSpecSelectors } from "~/server/developer-api/specs"; +import type { GearItem } from "~/types/gear"; + +function createGearItem(overrides: Partial): GearItem { + return { + id: "gear-1", + slug: "gear-1", + name: "Gear 1", + brandId: null, + gearType: "CAMERA", + mountId: null, + announcedDate: null, + announceDatePrecision: null, + releaseDate: null, + releaseDatePrecision: null, + ...overrides, + } as GearItem; +} + +describe("developer API spec registry", () => { + it("uses custom API categories without exposing hidden website fields", () => { + const catalog = getDeveloperApiSpecCatalog(); + const sensor = catalog.find((category) => category.id === "camera.sensor"); + + expect(sensor?.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "camera.sensor.isoRange" }), + ]), + ); + expect( + catalog.find((category) => category.id === "camera.shutter")?.fields, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "camera.shutter.availableShutterTypes", + }), + ]), + ); + expect( + catalog.find((category) => category.id === "camera.fixed-lens")?.fields, + ).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "camera.fixed-lens.focalLength" }), + ]), + ); + expect( + catalog.some((category) => category.id === "camera-sensor-shutter"), + ).toBe(false); + expect(catalog.flatMap((category) => category.fields)).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "fixed-lens.fixedImageCircleSize" }), + expect.objectContaining({ id: "camera.video.videoSummary" }), + expect.objectContaining({ + id: "camera.video.videoAvailableCodecs", + }), + ]), + ); + }); + + it("expands categories in registry order and deduplicates mixed selectors", () => { + const selected = resolveDeveloperApiSpecSelectors([ + "camera.sensor.isoRange", + "camera.sensor", + ]); + + expect(selected[0]?.field.api.id).toBe("camera.sensor.isoRange"); + expect(new Set(selected.map((field) => field.field.api.id)).size).toBe( + selected.length, + ); + expect(selected.map((field) => field.field.api.id)).not.toContain( + "camera.shutter.availableShutterTypes", + ); + + expect( + resolveDeveloperApiSpecSelectors(["camera.shutter"]).map( + (field) => field.field.api.id, + ), + ).toContain("camera.shutter.availableShutterTypes"); + }); + + it("keeps grouped raw values structured while using the existing display by default", () => { + const field = getDeveloperApiSpecFields().find( + (definition) => definition.field.api.id === "camera.sensor.isoRange", + ); + const value = getDeveloperApiSpecValue( + createGearItem({ + cameraSpecs: { isoMin: 100, isoMax: 51200 } as GearItem["cameraSpecs"], + }), + field!, + ); + + expect(value).toEqual({ + id: "camera.sensor.isoRange", + raw: { min: 100, max: 51200 }, + display: "ISO 100 - 51200", + }); + }); + + it("limits sensor type raw data to its source components", () => { + const field = getDeveloperApiSpecFields().find( + (definition) => definition.field.api.id === "camera.sensor.sensorType", + ); + const value = getDeveloperApiSpecValue( + createGearItem({ + cameraSpecs: { + gearId: "internal-gear-id", + sensorStackingType: "fully-stacked", + sensorTechType: "cmos", + isBackSideIlluminated: true, + } as GearItem["cameraSpecs"], + }), + field!, + ); + + expect(value).toEqual({ + id: "camera.sensor.sensorType", + raw: { + sensorStackingType: "fully-stacked", + sensorTechType: "cmos", + isBackSideIlluminated: true, + }, + display: "Stacked BSI-CMOS", + }); + }); + + it("rejects selectors that are not in the live catalog", () => { + expect(() => resolveDeveloperApiSpecSelectors(["camera.unknown"])).toThrow( + DeveloperApiError, + ); + expect(() => + resolveDeveloperApiSpecSelectors(["camera.video.videoSummary"]), + ).toThrow(DeveloperApiError); + }); +}); diff --git a/tests/unit/developer-docs-page.test.ts b/tests/unit/developer-docs-page.test.ts new file mode 100644 index 00000000..4f5b8ea0 --- /dev/null +++ b/tests/unit/developer-docs-page.test.ts @@ -0,0 +1,102 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DeveloperApiError } from "~/server/developer-api/errors"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getTranslations: vi.fn(), + getDeveloperApiSpecsCatalog: vi.fn(), + redirect: vi.fn(), + requireDeveloperPortalUser: vi.fn(), +})); + +vi.mock("next-intl/server", () => ({ + getTranslations: mocks.getTranslations, +})); + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +vi.mock("next/headers", () => ({ headers: vi.fn(async () => new Headers()) })); + +vi.mock("next/navigation", () => ({ redirect: mocks.redirect })); + +vi.mock("~/auth", () => ({ + auth: { api: { getSession: mocks.getSession } }, +})); + +vi.mock("~/server/developer-api/service", () => ({ + requireDeveloperPortalUser: mocks.requireDeveloperPortalUser, +})); + +vi.mock("~/server/developer-api/specs", () => ({ + getDeveloperApiSpecsCatalog: mocks.getDeveloperApiSpecsCatalog, +})); + +import DeveloperDocsPage from "~/app/[locale]/(pages)/developer/docs/page"; + +describe("DeveloperDocsPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getSession.mockResolvedValue({ user: { id: "user-1" } }); + mocks.requireDeveloperPortalUser.mockResolvedValue({ id: "user-1" }); + mocks.getDeveloperApiSpecsCatalog.mockReturnValue([ + { + id: "camera.sensor", + label: "Camera sensor", + fields: [ + { + id: "camera.sensor.isoRange", + label: "ISO Range", + searchTerms: [], + }, + ], + }, + ]); + mocks.getTranslations.mockResolvedValue((key: string) => key); + }); + + it("renders the documented developer API endpoints for approved users", async () => { + const markup = renderToStaticMarkup( + await DeveloperDocsPage({ params: Promise.resolve({ locale: "en" }) }), + ); + + expect(markup).toContain("/api/v1/search"); + expect(markup).toContain("/api/v1/specs"); + expect(markup).toContain("/api/v1/gear/:slug/specs"); + expect(markup).toContain("camera.sensor"); + expect(markup).toContain("camera.sensor.isoRange"); + expect(markup).toContain('aria-haspopup="dialog"'); + expect(markup).not.toContain("", + "/api/v1/gear/:slug", + "/api/v1/gear/:slug/specs", + "/api/v1/specs", + ].map((endpoint) => markup.indexOf(endpoint)); + expect(endpointOrder).toEqual([...endpointOrder].sort((a, b) => a - b)); + }); + + it("redirects users without developer access back to the portal", async () => { + mocks.requireDeveloperPortalUser.mockRejectedValue( + new DeveloperApiError("developer_access_required", 403, "Denied"), + ); + mocks.redirect.mockImplementation((href: string) => { + throw new Error(`redirect:${href}`); + }); + + await expect( + DeveloperDocsPage({ params: Promise.resolve({ locale: "en" }) }), + ).rejects.toThrow("redirect:/developer"); + }); +}); diff --git a/tests/unit/header-model.test.ts b/tests/unit/header-model.test.ts index a3c16525..d558083f 100644 --- a/tests/unit/header-model.test.ts +++ b/tests/unit/header-model.test.ts @@ -1,16 +1,17 @@ -import { describe,expect,it } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildHeaderCallbackUrl, buildHeaderRouteState, buildHeaderViewModel, type HeaderLabels, } from "~/components/layout/header-model"; -import { getFooterItems,getNavItems } from "~/lib/nav-items"; +import { getFooterItems, getNavItems } from "~/lib/nav-items"; const t = (key: string) => key; const labels: HeaderLabels = { adminPanel: "Admin Panel", + developerPortal: "Developer Portal", signIn: "Sign In", profile: "Profile", account: "Account", @@ -52,9 +53,12 @@ describe("header model", () => { expect(model.homeHref).toBe("/ja"); expect(model.adminHref).toBe("/ja/admin"); expect(model.accountHref).toBe("/ja/profile/settings"); + expect(model.developerHref).toBe("/ja/developer"); expect(model.navItems[0]?.href).toBe("/ja/about"); expect(model.footerItems.bottomLinks).toEqual( - expect.arrayContaining([expect.objectContaining({ href: "/ja/contact" })]), + expect.arrayContaining([ + expect.objectContaining({ href: "/ja/contact" }), + ]), ); }); @@ -70,5 +74,6 @@ describe("header model", () => { expect(model.homeHref).toBe("/"); expect(model.adminHref).toBe("/admin"); expect(model.accountHref).toBe("/profile/settings"); + expect(model.developerHref).toBe("/developer"); }); }); diff --git a/tests/unit/header-server.test.ts b/tests/unit/header-server.test.ts index 8f0629d0..fcbd3493 100644 --- a/tests/unit/header-server.test.ts +++ b/tests/unit/header-server.test.ts @@ -1,5 +1,5 @@ import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach,describe,expect,it,vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { HeaderViewModel } from "~/components/layout/header-model"; const intlServerMocks = vi.hoisted(() => ({ @@ -37,13 +37,9 @@ describe("header server component", () => { beforeEach(() => { vi.clearAllMocks(); intlServerMocks.getTranslations.mockImplementation( - async ({ - namespace, - }: { - locale: string; - namespace: "common" | "nav"; - }) => - (key: string) => `${namespace}.${key}`, + async ({ namespace }: { locale: string; namespace: "common" | "nav" }) => + (key: string) => + `${namespace}.${key}`, ); }); @@ -56,6 +52,7 @@ describe("header server component", () => { expect(model.homeHref).toBe("/ja"); expect(model.adminHref).toBe("/ja/admin"); expect(model.accountHref).toBe("/ja/profile/settings"); + expect(model.developerHref).toBe("/ja/developer"); expect(model.labels.signIn).toBe("common.signIn"); expect(model.moreLabel).toBe("nav.more"); }); @@ -68,8 +65,10 @@ describe("header server component", () => { homeHref: "/", adminHref: "/admin", accountHref: "/profile/settings", + developerHref: "/developer", labels: { adminPanel: "common.adminPanel", + developerPortal: "common.developerPortal", signIn: "common.signIn", profile: "common.profile", account: "common.account", diff --git a/tests/unit/user-menu.test.ts b/tests/unit/user-menu.test.ts new file mode 100644 index 00000000..04b0256a --- /dev/null +++ b/tests/unit/user-menu.test.ts @@ -0,0 +1,67 @@ +import { createElement, type ReactNode } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/ui/avatar", () => ({ + Avatar: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + AvatarFallback: ({ children }: { children: ReactNode }) => + createElement("span", null, children), + AvatarImage: () => null, +})); + +vi.mock("@/components/ui/dropdown-menu", () => ({ + DropdownMenu: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + DropdownMenuContent: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + DropdownMenuItem: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + DropdownMenuLabel: ({ children }: { children: ReactNode }) => + createElement("div", null, children), + DropdownMenuSeparator: () => createElement("hr"), + DropdownMenuTrigger: ({ children }: { children: ReactNode }) => + createElement("div", null, children), +})); + +vi.mock("~/lib/auth", () => ({ logOut: vi.fn() })); + +import { UserMenu } from "~/components/layout/user-menu"; + +const labels = { + account: "Account", + anonymous: "Anonymous", + developerPortal: "Developer Portal", + logOut: "Log out", + profile: "Profile", +}; + +function renderMenu(developerAccessEnabled: boolean) { + return renderToStaticMarkup( + createElement(UserMenu, { + user: { + id: "user-1", + role: "USER", + name: "Ava", + developerAccessEnabled, + }, + labels, + profileHref: "/u/ava", + accountHref: "/profile/settings", + developerHref: "/developer", + }), + ); +} + +describe("UserMenu", () => { + it("shows the developer portal only for users with developer access", () => { + const markup = renderMenu(true); + + expect(markup).toContain("Developer Portal"); + expect(markup).toContain('href="/developer"'); + }); + + it("hides the developer portal for users without developer access", () => { + expect(renderMenu(false)).not.toContain("Developer Portal"); + }); +});