@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
@@ -0,0 +1,8 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
@@ -0,0 +1,11 @@
|
||||
NODE_ENV=development
|
||||
APP_NAME=Beacon
|
||||
APP_URL=http://localhost:3000
|
||||
DATABASE_URL=postgresql://beacon:beacon@localhost:5432/beacon
|
||||
BETTER_AUTH_SECRET=replace-with-a-32-character-minimum-secret
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
LOG_LEVEL=info
|
||||
TMDB_API_TOKEN=
|
||||
ANILIST_API_URL=https://graphql.anilist.co
|
||||
TWITCH_CLIENT_ID=
|
||||
TWITCH_CLIENT_SECRET=
|
||||
@@ -0,0 +1,17 @@
|
||||
name: CI
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 10.13.1 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 24, cache: pnpm }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm format:check
|
||||
- run: pnpm lint
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- run: pnpm build
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
.env
|
||||
.env.local
|
||||
coverage
|
||||
playwright-report
|
||||
test-results
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,4 @@
|
||||
.next
|
||||
node_modules
|
||||
pnpm-lock.yaml
|
||||
drizzle/migrations
|
||||
@@ -0,0 +1,23 @@
|
||||
# Beacon agent guide
|
||||
|
||||
## Purpose and stack
|
||||
|
||||
Beacon helps small communities discover media through trusted recommendations. It is a Next.js 16 / React 19 / TypeScript strict modular monolith using PostgreSQL, Drizzle, Better Auth, Tailwind and Vitest.
|
||||
|
||||
## Architecture rules
|
||||
|
||||
Use Server Components by default. Never access Drizzle from React components, pages, route handlers or Server Actions: use a feature repository/application module. Keep domain names neutral (`Recommendation`, never a branding service). Validate session, membership, role and resource workspace on the server. Encapsulate external providers; do not expose secrets or raw provider payloads.
|
||||
|
||||
Do not add dependencies without justification, `any`, ignored TypeScript errors, microservices, Redis, direct schema push in production, edited applied migrations, unnecessary client components, real test data, dead files, or removed tests to pass CI. Update an ADR when changing architecture.
|
||||
|
||||
## Conventions
|
||||
|
||||
Use kebab-case filenames, PascalCase components, `@/` imports and public `index.ts` only for deliberate module boundaries. Zod validates server input and environment. Throw typed application errors, log structured context without secrets, and test behavior rather than implementation. Use conventional commits.
|
||||
|
||||
## Workflow
|
||||
|
||||
Before work, read this guide and `docs/`, inspect the target module and its invariants, then make the smallest coherent change. After work run `pnpm format`, `pnpm lint`, `pnpm typecheck`, `pnpm test`, and `pnpm build` when relevant; use Podman for local containers; report files, verification and risks.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
Do not add real metadata integrations, recommendation algorithms, AI, mobile apps, push notifications, gamification, episode tracking, bulk imports, social login, WebSockets or job queues yet.
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
FROM node:24-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@10.13.1 --activate
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dependencies
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS production-dependencies
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM dependencies AS builder
|
||||
COPY . .
|
||||
# Next evaluates route modules while building. These inert values only satisfy
|
||||
# configuration parsing at build time; Compose/runtime values replace them.
|
||||
ENV APP_URL=http://build.local \
|
||||
DATABASE_URL=postgresql://build:build@localhost:5432/build \
|
||||
BETTER_AUTH_SECRET=build-only-secret-that-is-at-least-thirty-two-characters \
|
||||
BETTER_AUTH_URL=http://build.local
|
||||
RUN pnpm build
|
||||
|
||||
FROM node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=3000 HOSTNAME=0.0.0.0
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 beacon
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=beacon:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=beacon:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=beacon:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=beacon:nodejs /app/scripts ./scripts
|
||||
COPY --from=production-dependencies --chown=beacon:nodejs /app/node_modules ./node_modules
|
||||
USER beacon
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
ENTRYPOINT ["/bin/sh", "./scripts/entrypoint.sh"]
|
||||
@@ -1,2 +1,33 @@
|
||||
# Beacon
|
||||
|
||||
Beacon is a private, multi-workspace web application for small communities to discover films, series, anime and games through people they trust.
|
||||
|
||||
## Stack
|
||||
|
||||
Node.js 24 LTS, pnpm 10, Next.js 16 App Router, React 19, TypeScript strict, Tailwind CSS, Better Auth, PostgreSQL 17 and Drizzle ORM. The supported runtime is Node 22+.
|
||||
|
||||
## Local development
|
||||
|
||||
1. Copy `.env.example` to `.env` and set a unique `BETTER_AUTH_SECRET` of 32+ characters.
|
||||
2. Start PostgreSQL: `podman compose -f docker-compose.dev.yml up postgres -d`.
|
||||
3. `pnpm install`
|
||||
4. `pnpm db:migrate && pnpm db:seed`
|
||||
5. `pnpm dev`
|
||||
|
||||
Open `http://localhost:3000`. Development accounts are `patch@beacon.local`, `rook@beacon.local` and `nova@beacon.local`; their passwords are respectively `BeaconDev!Patch2026`, `BeaconDev!Rook2026` and `BeaconDev!Nova2026`. They are strictly local development credentials.
|
||||
|
||||
## Commands
|
||||
|
||||
`pnpm lint`, `pnpm typecheck`, `pnpm test`, `pnpm build`, `pnpm test:e2e`, `pnpm db:generate`, `pnpm db:migrate`, `pnpm db:seed`, and `pnpm podman:dev` are the primary commands. `GET /api/health` reports application and database readiness.
|
||||
|
||||
## Docker and deployment
|
||||
|
||||
`podman compose -f docker-compose.dev.yml up --build` runs the app and PostgreSQL. The image uses the standard Dockerfile format, Next standalone output and a non-root user; startup waits for PostgreSQL and applies versioned migrations, never seeds. It is suitable as a single web service plus PostgreSQL in Coolify.
|
||||
|
||||
## Architecture
|
||||
|
||||
The application is a modular monolith. Pages and route handlers coordinate application code; repositories own Drizzle access; server guards validate session, membership and roles. Routes explicitly include their active workspace (`/w/el-yermo`). See [architecture.md](docs/architecture.md), [domain-model.md](docs/domain-model.md), and the initial [ADR](docs/decisions/0001-initial-architecture.md).
|
||||
|
||||
## Immediate roadmap
|
||||
|
||||
The next iteration will add TMDB movie and series search with normalized persistence, caching and recommendation creation. AniList and IGDB are currently contracts only; no external provider calls exist.
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB:-beacon}
|
||||
POSTGRES_USER: ${POSTGRES_USER:-beacon}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-beacon}
|
||||
ports: ["5432:5432"]
|
||||
volumes: ["postgres_data:/var/lib/postgresql/data"]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
APP_NAME: Beacon
|
||||
APP_URL: http://localhost:3000
|
||||
BETTER_AUTH_URL: http://localhost:3000
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?Set BETTER_AUTH_SECRET in .env}
|
||||
DATABASE_URL: postgresql://${POSTGRES_USER:-beacon}:${POSTGRES_PASSWORD:-beacon}@postgres:5432/${POSTGRES_DB:-beacon}
|
||||
LOG_LEVEL: info
|
||||
ports: ["3000:3000"]
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,14 @@
|
||||
# Architecture
|
||||
|
||||
Beacon is a single Next.js deployment connected to PostgreSQL. Presentation (`src/app`, `src/components`) calls server application modules; repositories in `src/features/*/repositories` are the only path to Drizzle. Domain language remains `Recommendation`, `Workspace` and `MediaItem`; “Beacon” is presentation branding.
|
||||
|
||||
```text
|
||||
Browser → App Router → server guard/use case → repository → Drizzle → PostgreSQL
|
||||
↘ metadata provider contract (future)
|
||||
```
|
||||
|
||||
Server Components are the default. Client Components are limited to theme selection, mobile active navigation and the login form. Better Auth owns credential hashing and session cookies. `requireUser`, `requireWorkspaceMember` and `requireWorkspaceRole` validate authorization on the server; middleware is only an early redirect optimization.
|
||||
|
||||
Errors use typed application errors and route handlers map them to safe HTTP responses. Pino redacts secrets. Environment is parsed by Zod at startup. Tests use Vitest for unit contracts; integration tests are reserved for a PostgreSQL test database and Playwright covers browser routing. UI strings are presently English and concentrated in components/configuration; a dedicated i18n library is intentionally deferred.
|
||||
|
||||
External metadata adapters normalize results to `MediaSearchResult` and `MediaDetails`; raw provider responses never cross the application boundary. The cache table is ready for future use.
|
||||
@@ -0,0 +1,11 @@
|
||||
# ADR 0001: Initial architecture
|
||||
|
||||
## Decision
|
||||
|
||||
Use a modular Next.js monolith with PostgreSQL, Drizzle and Better Auth. Model workspaces from the start and use explicit workspace paths. Keep metadata suppliers behind adapters.
|
||||
|
||||
## Rationale
|
||||
|
||||
The expected scale is a small private community, so a single deployable is simpler to operate and evolve. PostgreSQL gives transactional constraints for memberships and recommendations. Drizzle provides reviewed SQL migrations without schema synchronization in production. Better Auth provides password and session primitives without a mandatory cloud service.
|
||||
|
||||
No Redis, queues or microservices are introduced: no demonstrated workload requires them. A future worker can consume the same database and provider contracts without changing the web application's domain model.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Domain model
|
||||
|
||||
`User` is an authenticated account. A `Workspace` is a community and `WorkspaceMember` assigns its ADMIN or MEMBER role. A user may be in multiple workspaces.
|
||||
|
||||
`MediaItem` is one external or manual work. Manual works use `MANUAL` plus an application-generated external identifier. A `Recommendation` is the one active personal recommendation for a media item in a workspace; its uniqueness is `(workspaceId, mediaItemId)`. `RecommendationStatus` is per user and recommendation, while reactions and comments reinforce the existing recommendation instead of duplicating it.
|
||||
|
||||
Provider/external ID identifies media globally. A work can appear in different workspaces. Soft deletion is available for recommendations and comments. Invitation tokens are stored hashed. Audit metadata is deliberately non-sensitive.
|
||||
|
||||
Status values are PENDING, INTERESTED, IN_PROGRESS, COMPLETED, DROPPED and NOT_INTERESTED. Strength values NORMAL, HIGH and MUST_EXPERIENCE express insistence, not quality. Ratings are optional and intended for completed works.
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
export default defineConfig({
|
||||
schema: "./src/db/schema/index.ts",
|
||||
out: "./drizzle/migrations",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgresql://beacon:beacon@localhost:5432/beacon",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
CREATE TYPE "public"."media_type" AS ENUM('MOVIE', 'TV_SERIES', 'ANIME', 'VIDEO_GAME');--> statement-breakpoint
|
||||
CREATE TYPE "public"."metadata_provider" AS ENUM('TMDB', 'ANILIST', 'IGDB', 'MANUAL');--> statement-breakpoint
|
||||
CREATE TYPE "public"."reaction_type" AS ENUM('INTERESTED', 'ENDORSE');--> statement-breakpoint
|
||||
CREATE TYPE "public"."personal_recommendation_status" AS ENUM('PENDING', 'INTERESTED', 'IN_PROGRESS', 'COMPLETED', 'DROPPED', 'NOT_INTERESTED');--> statement-breakpoint
|
||||
CREATE TYPE "public"."recommendation_strength" AS ENUM('NORMAL', 'HIGH', 'MUST_EXPERIENCE');--> statement-breakpoint
|
||||
CREATE TYPE "public"."workspace_role" AS ENUM('ADMIN', 'MEMBER');--> statement-breakpoint
|
||||
CREATE TABLE "account" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"account_id" text NOT NULL,
|
||||
"provider_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"access_token" text,
|
||||
"refresh_token" text,
|
||||
"id_token" text,
|
||||
"access_token_expires_at" timestamp with time zone,
|
||||
"refresh_token_expires_at" timestamp with time zone,
|
||||
"scope" text,
|
||||
"password" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "session" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"ip_address" text,
|
||||
"user_agent" text,
|
||||
"user_id" text NOT NULL,
|
||||
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "user" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"email_verified" boolean DEFAULT false NOT NULL,
|
||||
"image" text,
|
||||
"disabled_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "verification" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text,
|
||||
"actor_user_id" text,
|
||||
"action" text NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"entity_id" text NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "comment" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"recommendation_id" text NOT NULL,
|
||||
"author_user_id" text NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"contains_spoilers" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "media_item" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"provider" "metadata_provider" NOT NULL,
|
||||
"external_id" text NOT NULL,
|
||||
"type" "media_type" NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"original_title" text,
|
||||
"description" text,
|
||||
"release_date" text,
|
||||
"release_year" integer,
|
||||
"poster_url" text,
|
||||
"backdrop_url" text,
|
||||
"runtime_minutes" integer,
|
||||
"genres" text[] DEFAULT '{}' NOT NULL,
|
||||
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"metadata_updated_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "metadata_search_cache" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"provider" "metadata_provider" NOT NULL,
|
||||
"media_type" "media_type" NOT NULL,
|
||||
"normalized_query" text NOT NULL,
|
||||
"response" jsonb NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recommendation_reaction" (
|
||||
"recommendation_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"type" "reaction_type" NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "recommendation_reaction_recommendation_id_user_id_type_pk" PRIMARY KEY("recommendation_id","user_id","type")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recommendation_status" (
|
||||
"recommendation_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"status" "personal_recommendation_status" DEFAULT 'PENDING' NOT NULL,
|
||||
"rating" integer,
|
||||
"review" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "recommendation_status_recommendation_id_user_id_pk" PRIMARY KEY("recommendation_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "recommendation" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"media_item_id" text NOT NULL,
|
||||
"recommended_by_user_id" text NOT NULL,
|
||||
"reason" text NOT NULL,
|
||||
"audience_note" text,
|
||||
"strength" "recommendation_strength" DEFAULT 'NORMAL' NOT NULL,
|
||||
"contains_spoilers" boolean DEFAULT false NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deleted_at" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspace_invitation" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"workspace_id" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"role" "workspace_role" DEFAULT 'MEMBER' NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"accepted_at" timestamp with time zone,
|
||||
"created_by_user_id" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workspace_invitation_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspace_member" (
|
||||
"workspace_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"role" "workspace_role" DEFAULT 'MEMBER' NOT NULL,
|
||||
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workspace_member_workspace_id_user_id_pk" PRIMARY KEY("workspace_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "workspace" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"description" text,
|
||||
"image_url" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "workspace_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "audit_log" ADD CONSTRAINT "audit_log_actor_user_id_user_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comment" ADD CONSTRAINT "comment_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "comment" ADD CONSTRAINT "comment_author_user_id_user_id_fk" FOREIGN KEY ("author_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_reaction" ADD CONSTRAINT "recommendation_reaction_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_reaction" ADD CONSTRAINT "recommendation_reaction_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_status" ADD CONSTRAINT "recommendation_status_recommendation_id_recommendation_id_fk" FOREIGN KEY ("recommendation_id") REFERENCES "public"."recommendation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation_status" ADD CONSTRAINT "recommendation_status_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation" ADD CONSTRAINT "recommendation_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation" ADD CONSTRAINT "recommendation_media_item_id_media_item_id_fk" FOREIGN KEY ("media_item_id") REFERENCES "public"."media_item"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "recommendation" ADD CONSTRAINT "recommendation_recommended_by_user_id_user_id_fk" FOREIGN KEY ("recommended_by_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invitation" ADD CONSTRAINT "workspace_invitation_created_by_user_id_user_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."user"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_member" ADD CONSTRAINT "workspace_member_workspace_id_workspace_id_fk" FOREIGN KEY ("workspace_id") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "workspace_member" ADD CONSTRAINT "workspace_member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "account_user_id_idx" ON "account" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "session_user_id_idx" ON "session" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "comment_recommendation_created_idx" ON "comment" USING btree ("recommendation_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "media_item_provider_external_id_unique" ON "media_item" USING btree ("provider","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "media_item_type_idx" ON "media_item" USING btree ("type");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "metadata_search_cache_unique" ON "metadata_search_cache" USING btree ("provider","media_type","normalized_query");--> statement-breakpoint
|
||||
CREATE INDEX "recommendation_status_user_idx" ON "recommendation_status" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "recommendation_workspace_media_unique" ON "recommendation" USING btree ("workspace_id","media_item_id");--> statement-breakpoint
|
||||
CREATE INDEX "recommendation_workspace_created_idx" ON "recommendation" USING btree ("workspace_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "workspace_invitation_email_active_idx" ON "workspace_invitation" USING btree ("workspace_id","email");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_invitation_workspace_idx" ON "workspace_invitation" USING btree ("workspace_id");--> statement-breakpoint
|
||||
CREATE INDEX "workspace_member_user_idx" ON "workspace_member" USING btree ("user_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1785139678448,
|
||||
"tag": "0000_funny_paibok",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([".next/**", "node_modules/**", "drizzle/**/meta/**", "scripts/**/*.js"]),
|
||||
]);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
export function middleware(request: NextRequest) {
|
||||
if (request.nextUrl.pathname.startsWith("/w/")) {
|
||||
const hasSessionCookie = request.cookies
|
||||
.getAll()
|
||||
.some((cookie) => cookie.name.includes("session_token"));
|
||||
if (!hasSessionCookie)
|
||||
return NextResponse.redirect(
|
||||
new URL(`/login?next=${encodeURIComponent(request.nextUrl.pathname)}`, request.url),
|
||||
);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
export const config = { matcher: ["/w/:path*"] };
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = { output: "standalone" };
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"name": "beacon",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier . --write",
|
||||
"format:check": "prettier . --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "node --env-file=.env ./node_modules/drizzle-kit/bin.cjs migrate",
|
||||
"db:seed": "tsx --env-file=.env src/db/seed/index.ts",
|
||||
"db:studio": "node --env-file=.env ./node_modules/drizzle-kit/bin.cjs studio",
|
||||
"docker:dev": "docker compose -f docker-compose.dev.yml up --build",
|
||||
"podman:dev": "podman compose -f docker-compose.dev.yml up --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"better-auth": "^1.3.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "^16.0.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^9.6.0",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^4.0.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.54.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"drizzle-kit": "^0.31.4",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.9",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:3000",
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
});
|
||||
Generated
+7214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
||||
export default config;
|
||||
@@ -0,0 +1,2 @@
|
||||
const config = { plugins: ["prettier-plugin-tailwindcss"], printWidth: 100, trailingComma: "all" };
|
||||
export default config;
|
||||
@@ -0,0 +1,4 @@
|
||||
# Branding assets
|
||||
|
||||
This directory is reserved for local Beacon brand assets. Keep future images
|
||||
replaceable and avoid coupling domain code to product branding.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Local placeholders
|
||||
|
||||
Use this directory for deterministic development media placeholders. Do not
|
||||
depend on external metadata-provider images for seeds or tests.
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
node ./scripts/wait-for-database.js
|
||||
node ./scripts/migrate.js
|
||||
exec node server.js
|
||||
@@ -0,0 +1,14 @@
|
||||
const path = require("node:path");
|
||||
const postgres = require("postgres");
|
||||
const { drizzle } = require("drizzle-orm/postgres-js");
|
||||
const { migrate } = require("drizzle-orm/postgres-js/migrator");
|
||||
(async () => {
|
||||
const client = postgres(process.env.DATABASE_URL, { max: 1 });
|
||||
await migrate(drizzle(client), {
|
||||
migrationsFolder: path.join(process.cwd(), "drizzle/migrations"),
|
||||
});
|
||||
await client.end();
|
||||
})().catch((error) => {
|
||||
console.error("Migration failed", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const postgres = require("postgres");
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required.");
|
||||
const client = postgres(databaseUrl, { max: 1 });
|
||||
(async () => {
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
try {
|
||||
await client`select 1`;
|
||||
await client.end();
|
||||
return;
|
||||
} catch {
|
||||
if (attempt === 30) {
|
||||
await client.end();
|
||||
throw new Error("Database was not reachable after 30 attempts.");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error("Database wait failed", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import postgres from "postgres";
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error("DATABASE_URL is required.");
|
||||
const client = postgres(databaseUrl, { max: 1 });
|
||||
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
||||
try {
|
||||
await client`select 1`;
|
||||
await client.end();
|
||||
process.exit(0);
|
||||
} catch {
|
||||
if (attempt === 30) {
|
||||
await client.end();
|
||||
throw new Error("Database was not reachable after 30 attempts.");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default function InvitationPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg p-8">
|
||||
<EmptyState
|
||||
title="Accept invitation"
|
||||
description="Invitation token validation will be enabled with the member management flow."
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default function ForgotPasswordPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg p-8">
|
||||
<EmptyState
|
||||
title="Password recovery is being prepared"
|
||||
description="Email delivery is intentionally encapsulated for the next iteration."
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import Link from "next/link";
|
||||
import { LoginForm } from "@/components/auth/login-form";
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center p-4">
|
||||
<section className="w-full max-w-sm rounded-xl border border-border bg-surface p-6 shadow-sm">
|
||||
<p className="text-sm font-medium text-primary">Beacon</p>
|
||||
<h1 className="mt-2 text-2xl font-semibold">Welcome back</h1>
|
||||
<p className="mb-6 mt-1 text-sm text-muted-foreground">
|
||||
Sign in to discover through your crew.
|
||||
</p>
|
||||
<LoginForm />
|
||||
<Link
|
||||
className="mt-5 block text-center text-sm text-muted-foreground underline"
|
||||
href="/forgot-password"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-lg p-8">
|
||||
<EmptyState
|
||||
title="Reset your password"
|
||||
description="This flow will be enabled when the mail adapter is configured."
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { requireWorkspaceMember } from "@/lib/auth/guards";
|
||||
export default async function CrewPage({ params }: { params: Promise<{ workspaceSlug: string }> }) {
|
||||
const { workspaceSlug } = await params;
|
||||
const { workspace } = await requireWorkspaceMember(workspaceSlug);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Crew" description={`People discovering together in ${workspace.name}.`} />
|
||||
<p className="rounded-lg border border-border bg-surface p-5 text-sm text-muted-foreground">
|
||||
Crew activity and member management are available from workspace settings.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default function DiscoverPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Discover"
|
||||
description="Find something your crew will want to talk about."
|
||||
/>
|
||||
<div className="max-w-2xl">
|
||||
<Input
|
||||
aria-label="Search the future metadata catalog"
|
||||
placeholder="Search movies, series, anime, or games"
|
||||
disabled
|
||||
/>
|
||||
<div className="mt-4 flex gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-full border px-3 py-1">Movies</span>
|
||||
<span className="rounded-full border px-3 py-1">Series</span>
|
||||
<span className="rounded-full border px-3 py-1">Anime</span>
|
||||
<span className="rounded-full border px-3 py-1">Games</span>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<EmptyState
|
||||
title="Metadata search is not connected yet"
|
||||
description="Provider contracts are ready; TMDB, AniList, and IGDB calls are deliberately out of scope for this iteration."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { requirePageUser } from "@/lib/auth/guards";
|
||||
export default async function WorkspaceLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
await requirePageUser();
|
||||
const { workspaceSlug } = await params;
|
||||
return <AppShell workspaceSlug={workspaceSlug}>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default function LibraryPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="My Library"
|
||||
description="Track what you want to experience with your crew."
|
||||
/>
|
||||
<nav className="mb-6 flex gap-2 overflow-x-auto text-sm">
|
||||
<span className="rounded-md bg-primary px-3 py-2 text-primary-foreground">Pending</span>
|
||||
{["Interested", "In Progress", "Completed", "Dropped"].map((name) => (
|
||||
<span key={name} className="rounded-md bg-secondary px-3 py-2">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
<EmptyState
|
||||
title="Your library is waiting"
|
||||
description="Update a recommendation status to see it here."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { RecommendationCard } from "@/components/recommendations/recommendation-card";
|
||||
import { requireWorkspaceMember } from "@/lib/auth/guards";
|
||||
import { listWorkspaceRecommendations } from "@/features/recommendations/repositories/recommendation-repository";
|
||||
import { EmptyState } from "@/components/states/empty-state";
|
||||
export default async function WorkspaceHome({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = await params;
|
||||
const { workspace } = await requireWorkspaceMember(workspaceSlug);
|
||||
const items = await listWorkspaceRecommendations(workspace.id);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Recent Beacons" description={`Recommendations from ${workspace.name}.`} />
|
||||
<section className="max-w-3xl space-y-4">
|
||||
{items.length ? (
|
||||
items.map((item) => <RecommendationCard key={item.id} recommendation={item} />)
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No recommendations yet"
|
||||
description="Your crew has not emitted any beacons."
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { PageHeader } from "@/components/layout/page-header";
|
||||
import { requireWorkspaceMember } from "@/lib/auth/guards";
|
||||
export default async function SettingsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ workspaceSlug: string }>;
|
||||
}) {
|
||||
const { workspaceSlug } = await params;
|
||||
const { membership } = await requireWorkspaceMember(workspaceSlug);
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Settings" description="Profile, appearance, and workspace preferences." />
|
||||
<section className="space-y-3 rounded-lg border border-border bg-surface p-5 text-sm">
|
||||
<p>
|
||||
<strong>Profile</strong> · personal details are managed by Better Auth.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Appearance</strong> · use the theme switcher in the sidebar.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Workspace members</strong> ·{" "}
|
||||
{membership.role === "ADMIN"
|
||||
? "Administrator controls will be enabled here."
|
||||
: "Only workspace administrators can manage members."}
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { toNextJsHandler } from "better-auth/next-js";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
export const { GET, POST } = toNextJsHandler(auth);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { logger } from "@/lib/logging";
|
||||
export async function GET() {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
return NextResponse.json({
|
||||
status: "ok",
|
||||
service: "beacon",
|
||||
database: "reachable",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, "Healthcheck database failure");
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: "unavailable",
|
||||
service: "beacon",
|
||||
database: "unreachable",
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
import { ErrorState } from "@/components/states/error-state";
|
||||
export default function Error() {
|
||||
return (
|
||||
<main className="p-8">
|
||||
<ErrorState />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
export default function GlobalError() {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<main className="p-8">An unexpected error occurred.</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 210 30% 98%;
|
||||
--surface: 0 0% 100%;
|
||||
--foreground: 222 35% 12%;
|
||||
--muted: 215 25% 93%;
|
||||
--muted-foreground: 215 16% 42%;
|
||||
--border: 215 22% 87%;
|
||||
--input: 215 22% 87%;
|
||||
--primary: 195 75% 38%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 215 25% 93%;
|
||||
--secondary-foreground: 222 35% 12%;
|
||||
--accent: 195 50% 93%;
|
||||
--accent-foreground: 195 65% 22%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 195 75% 38%;
|
||||
--radius: 0.65rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 222 34% 8%;
|
||||
--surface: 222 29% 11%;
|
||||
--foreground: 210 22% 94%;
|
||||
--muted: 221 22% 17%;
|
||||
--muted-foreground: 215 17% 65%;
|
||||
--border: 220 19% 22%;
|
||||
--input: 220 19% 22%;
|
||||
--primary: 190 74% 51%;
|
||||
--primary-foreground: 222 34% 8%;
|
||||
--secondary: 221 22% 17%;
|
||||
--secondary-foreground: 210 22% 94%;
|
||||
--accent: 192 35% 17%;
|
||||
--accent-foreground: 190 74% 72%;
|
||||
--destructive: 0 62% 48%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--ring: 190 74% 51%;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground antialiased;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { siteConfig } from "@/config/site";
|
||||
export const metadata: Metadata = {
|
||||
title: { default: siteConfig.name, template: `%s · ${siteConfig.name}` },
|
||||
description: siteConfig.description,
|
||||
};
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body>
|
||||
<ThemeProvider attribute="class" defaultTheme="dark" enableSystem>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||
export default function Loading() {
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl p-8">
|
||||
<LoadingSkeleton />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Link from "next/link";
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="mx-auto max-w-xl p-12 text-center">
|
||||
<h1 className="text-2xl font-semibold">Not found</h1>
|
||||
<p className="mt-2 text-muted-foreground">This signal does not exist.</p>
|
||||
<Link className="mt-5 inline-block text-primary underline" href="/">
|
||||
Return home
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requirePageUser } from "@/lib/auth/guards";
|
||||
import { getUserWorkspaces } from "@/features/workspaces/repositories/workspace-repository";
|
||||
export default async function RootPage() {
|
||||
const user = await requirePageUser();
|
||||
const workspaces = await getUserWorkspaces(user.id);
|
||||
if (workspaces[0]) redirect(`/w/${workspaces[0].workspace.slug}`);
|
||||
redirect("/login");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
const schema = z.object({ email: z.email(), password: z.string().min(1) });
|
||||
type Values = z.infer<typeof schema>;
|
||||
export function LoginForm() {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState<string>();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting, errors },
|
||||
} = useForm<Values>({ resolver: zodResolver(schema) });
|
||||
const onSubmit = async (values: Values) => {
|
||||
setError(undefined);
|
||||
const result = await authClient.signIn.email({ ...values });
|
||||
if (result.error) {
|
||||
setError("Invalid email or password.");
|
||||
return;
|
||||
}
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
};
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<label className="block text-sm font-medium">
|
||||
Email
|
||||
<Input className="mt-1" autoComplete="email" {...register("email")} />
|
||||
{errors.email && <span className="text-xs text-destructive">Enter a valid email.</span>}
|
||||
</label>
|
||||
<label className="block text-sm font-medium">
|
||||
Password
|
||||
<Input
|
||||
className="mt-1"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
{...register("password")}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<Button className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Signing in…" : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SidebarNavigation } from "./sidebar-navigation";
|
||||
import { MobileNavigation } from "./mobile-navigation";
|
||||
export function AppShell({
|
||||
workspaceSlug,
|
||||
children,
|
||||
}: {
|
||||
workspaceSlug: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<SidebarNavigation workspaceSlug={workspaceSlug} />
|
||||
<main className="mx-auto max-w-6xl px-4 py-6 pb-24 md:ml-64 md:px-8 md:pb-8">{children}</main>
|
||||
<MobileNavigation workspaceSlug={workspaceSlug} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { navigation } from "@/config/navigation";
|
||||
export function MobileNavigation({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<nav
|
||||
aria-label="Main navigation"
|
||||
className="fixed inset-x-0 bottom-0 z-20 flex justify-around border-t border-border bg-surface/95 px-2 py-2 backdrop-blur md:hidden"
|
||||
>
|
||||
{navigation.slice(0, 4).map(({ label, href, icon: Icon }) => {
|
||||
const target = `/w/${workspaceSlug}${href}`;
|
||||
return (
|
||||
<Link
|
||||
key={label}
|
||||
href={target}
|
||||
aria-current={pathname === target ? "page" : undefined}
|
||||
className="flex min-w-14 flex-col items-center gap-1 text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<Icon size={19} />
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from "react";
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { navigation } from "@/config/navigation";
|
||||
import { siteConfig } from "@/config/site";
|
||||
import { ThemeToggle } from "./theme-toggle";
|
||||
export function SidebarNavigation({ workspaceSlug }: { workspaceSlug: string }) {
|
||||
return (
|
||||
<aside className="fixed inset-y-0 hidden w-64 border-r border-border bg-surface p-5 md:block">
|
||||
<Link href={`/w/${workspaceSlug}`} className="text-xl font-semibold tracking-tight">
|
||||
{siteConfig.name}
|
||||
<span className="text-primary">.</span>
|
||||
</Link>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Discover through people.</p>
|
||||
<nav className="mt-10 space-y-1">
|
||||
{navigation.map(({ label, href, icon: Icon }) => (
|
||||
<Link
|
||||
key={label}
|
||||
href={`/w/${workspaceSlug}${href}`}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Icon size={18} />
|
||||
{label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="absolute bottom-5">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const dark = resolvedTheme === "dark";
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={dark ? "Use light theme" : "Use dark theme"}
|
||||
onClick={() => setTheme(dark ? "light" : "dark")}
|
||||
>
|
||||
{dark ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { MessageCircle, Sparkles, Users } from "lucide-react";
|
||||
import type { recommendationStrengthEnum } from "@/db/schema";
|
||||
type Strength = (typeof recommendationStrengthEnum.enumValues)[number];
|
||||
const labels: Record<Strength, string> = {
|
||||
NORMAL: "Normal",
|
||||
HIGH: "Highly recommended",
|
||||
MUST_EXPERIENCE: "Must experience",
|
||||
};
|
||||
export function RecommendationCard({
|
||||
recommendation,
|
||||
}: {
|
||||
recommendation: {
|
||||
reason: string;
|
||||
strength: Strength;
|
||||
mediaItem: {
|
||||
title: string;
|
||||
type: string;
|
||||
releaseYear: number | null;
|
||||
posterUrl: string | null;
|
||||
};
|
||||
recommendedBy: { name: string; image: string | null };
|
||||
statuses: { status: string }[];
|
||||
comments: unknown[];
|
||||
reactions: unknown[];
|
||||
};
|
||||
}) {
|
||||
const interested = recommendation.statuses.filter((item) => item.status === "INTERESTED").length;
|
||||
const completed = recommendation.statuses.filter((item) => item.status === "COMPLETED").length;
|
||||
return (
|
||||
<article className="rounded-xl border border-border bg-surface p-5 shadow-sm">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/15 text-xs text-primary">
|
||||
{recommendation.recommendedBy.name.slice(0, 1)}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="font-medium text-foreground">
|
||||
{recommendation.recommendedBy.name}
|
||||
</strong>{" "}
|
||||
emitted a beacon
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<div
|
||||
aria-label={`Poster placeholder for ${recommendation.mediaItem.title}`}
|
||||
className="flex h-24 w-16 shrink-0 items-center justify-center rounded bg-muted text-center text-[10px] text-muted-foreground"
|
||||
>
|
||||
{recommendation.mediaItem.title}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold">{recommendation.mediaItem.title}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{recommendation.mediaItem.type.replace("_", " ")} ·{" "}
|
||||
{recommendation.mediaItem.releaseYear ?? "—"}
|
||||
</p>
|
||||
<p className="mt-3 text-sm leading-6">{recommendation.reason}</p>
|
||||
<span className="mt-3 inline-flex items-center gap-1 rounded-full bg-primary/15 px-2.5 py-1 text-xs font-medium text-primary">
|
||||
<Sparkles size={13} />
|
||||
{labels[recommendation.strength]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="mt-5 flex gap-4 border-t border-border pt-3 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{interested} interested · {completed} completed
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<MessageCircle size={14} />
|
||||
{recommendation.comments.length} comments
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-dashed border-border p-10 text-center">
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{description}</p>
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export function ErrorState({
|
||||
message = "Something went wrong. Please try again.",
|
||||
}: {
|
||||
message?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
role="alert"
|
||||
className="rounded-lg border border-destructive/40 bg-destructive/10 p-5 text-sm"
|
||||
>
|
||||
{message}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function LoadingSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-3">
|
||||
<div className="h-5 w-36 rounded bg-muted" />
|
||||
<div className="h-40 rounded bg-muted" />
|
||||
<div className="h-40 rounded bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"use client";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ComponentProps } from "react";
|
||||
export function ThemeProvider(props: ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
const variants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
destructive: "bg-destructive text-destructive-foreground",
|
||||
},
|
||||
size: { default: "h-10 px-4 py-2", sm: "h-9 px-3", icon: "h-10 w-10" },
|
||||
},
|
||||
defaultVariants: { variant: "default", size: "default" },
|
||||
},
|
||||
);
|
||||
export interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof variants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
export function Button({ className, variant, size, asChild, ...props }: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return <Comp className={cn(variants({ variant, size }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseEnvironment } from "./env";
|
||||
const valid = {
|
||||
APP_URL: "http://localhost:3000",
|
||||
DATABASE_URL: "postgresql://user:pass@localhost:5432/db",
|
||||
BETTER_AUTH_URL: "http://localhost:3000",
|
||||
BETTER_AUTH_SECRET: "a".repeat(32),
|
||||
};
|
||||
describe("environment", () => {
|
||||
it("accepts required values", () => expect(parseEnvironment(valid).APP_NAME).toBe("Beacon"));
|
||||
it("rejects an insecure short auth secret", () =>
|
||||
expect(() => parseEnvironment({ ...valid, BETTER_AUTH_SECRET: "short" })).toThrow());
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
APP_NAME: z.string().min(1).default("Beacon"),
|
||||
APP_URL: z.url(),
|
||||
DATABASE_URL: z.string().url(),
|
||||
BETTER_AUTH_SECRET: z.string().min(32),
|
||||
BETTER_AUTH_URL: z.url(),
|
||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace"]).default("info"),
|
||||
TMDB_API_TOKEN: z.string().optional(),
|
||||
ANILIST_API_URL: z.url().default("https://graphql.anilist.co"),
|
||||
TWITCH_CLIENT_ID: z.string().optional(),
|
||||
TWITCH_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof schema>;
|
||||
export function parseEnvironment(input: Record<string, string | undefined>): Environment {
|
||||
return schema.parse(input);
|
||||
}
|
||||
export const env = parseEnvironment(process.env);
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Compass, Home, Library, Settings, Users } from "lucide-react";
|
||||
|
||||
export const navigation = [
|
||||
{ label: "Home", href: "", icon: Home },
|
||||
{ label: "Discover", href: "/discover", icon: Compass },
|
||||
{ label: "My Library", href: "/library", icon: Library },
|
||||
{ label: "Crew", href: "/crew", icon: Users },
|
||||
{ label: "Settings", href: "/settings", icon: Settings },
|
||||
] as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const siteConfig = {
|
||||
name: "Beacon",
|
||||
description: "Discover through people.",
|
||||
locale: "en",
|
||||
} as const;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "@/config/env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const client = postgres(env.DATABASE_URL, { max: 10, prepare: false });
|
||||
export const db = drizzle(client, { schema });
|
||||
export { client };
|
||||
@@ -0,0 +1,13 @@
|
||||
import { jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { users } from "./auth";
|
||||
import { workspaces } from "./workspaces";
|
||||
export const auditLogs = pgTable("audit_log", {
|
||||
id: text("id").primaryKey(),
|
||||
workspaceId: text("workspace_id").references(() => workspaces.id, { onDelete: "set null" }),
|
||||
actorUserId: text("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type").notNull(),
|
||||
entityId: text("entity_id").notNull(),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
export const users = pgTable("user", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull().unique(),
|
||||
emailVerified: boolean("email_verified").notNull().default(false),
|
||||
image: text("image"),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
export const sessions = pgTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
token: text("token").notNull().unique(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
},
|
||||
(table) => [index("session_user_id_idx").on(table.userId)],
|
||||
);
|
||||
export const accounts = pgTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
|
||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [index("account_user_id_idx").on(table.userId)],
|
||||
);
|
||||
export const verifications = pgTable("verification", {
|
||||
id: text("id").primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
value: text("value").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { users } from "./auth";
|
||||
import { recommendations } from "./recommendations";
|
||||
export const comments = pgTable(
|
||||
"comment",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
recommendationId: text("recommendation_id")
|
||||
.notNull()
|
||||
.references(() => recommendations.id, { onDelete: "cascade" }),
|
||||
authorUserId: text("author_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "restrict" }),
|
||||
body: text("body").notNull(),
|
||||
containsSpoilers: boolean("contains_spoilers").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
index("comment_recommendation_created_idx").on(table.recommendationId, table.createdAt),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
import { pgEnum } from "drizzle-orm/pg-core";
|
||||
export const workspaceRoleEnum = pgEnum("workspace_role", ["ADMIN", "MEMBER"]);
|
||||
export const mediaTypeEnum = pgEnum("media_type", ["MOVIE", "TV_SERIES", "ANIME", "VIDEO_GAME"]);
|
||||
export const metadataProviderEnum = pgEnum("metadata_provider", [
|
||||
"TMDB",
|
||||
"ANILIST",
|
||||
"IGDB",
|
||||
"MANUAL",
|
||||
]);
|
||||
export const recommendationStrengthEnum = pgEnum("recommendation_strength", [
|
||||
"NORMAL",
|
||||
"HIGH",
|
||||
"MUST_EXPERIENCE",
|
||||
]);
|
||||
export const recommendationStatusEnum = pgEnum("personal_recommendation_status", [
|
||||
"PENDING",
|
||||
"INTERESTED",
|
||||
"IN_PROGRESS",
|
||||
"COMPLETED",
|
||||
"DROPPED",
|
||||
"NOT_INTERESTED",
|
||||
]);
|
||||
export const reactionTypeEnum = pgEnum("reaction_type", ["INTERESTED", "ENDORSE"]);
|
||||
@@ -0,0 +1,9 @@
|
||||
export * from "./auth";
|
||||
export * from "./audit";
|
||||
export * from "./comments";
|
||||
export * from "./enums";
|
||||
export * from "./media";
|
||||
export * from "./metadata-cache";
|
||||
export * from "./recommendations";
|
||||
export * from "./relations";
|
||||
export * from "./workspaces";
|
||||
@@ -0,0 +1,28 @@
|
||||
import { index, integer, jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { mediaTypeEnum, metadataProviderEnum } from "./enums";
|
||||
export const mediaItems = pgTable(
|
||||
"media_item",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
provider: metadataProviderEnum("provider").notNull(),
|
||||
externalId: text("external_id").notNull(),
|
||||
type: mediaTypeEnum("type").notNull(),
|
||||
title: text("title").notNull(),
|
||||
originalTitle: text("original_title"),
|
||||
description: text("description"),
|
||||
releaseDate: text("release_date"),
|
||||
releaseYear: integer("release_year"),
|
||||
posterUrl: text("poster_url"),
|
||||
backdropUrl: text("backdrop_url"),
|
||||
runtimeMinutes: integer("runtime_minutes"),
|
||||
genres: text("genres").array().notNull().default([]),
|
||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().notNull().default({}),
|
||||
metadataUpdatedAt: timestamp("metadata_updated_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("media_item_provider_external_id_unique").on(table.provider, table.externalId),
|
||||
index("media_item_type_idx").on(table.type),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { jsonb, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { mediaTypeEnum, metadataProviderEnum } from "./enums";
|
||||
export const metadataSearchCache = pgTable(
|
||||
"metadata_search_cache",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
provider: metadataProviderEnum("provider").notNull(),
|
||||
mediaType: mediaTypeEnum("media_type").notNull(),
|
||||
normalizedQuery: text("normalized_query").notNull(),
|
||||
response: jsonb("response").$type<Record<string, unknown>>().notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("metadata_search_cache_unique").on(
|
||||
table.provider,
|
||||
table.mediaType,
|
||||
table.normalizedQuery,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { recommendationStatusEnum, recommendationStrengthEnum, reactionTypeEnum } from "./enums";
|
||||
import { users } from "./auth";
|
||||
import { mediaItems } from "./media";
|
||||
import { workspaces } from "./workspaces";
|
||||
export const recommendations = pgTable(
|
||||
"recommendation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
workspaceId: text("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
mediaItemId: text("media_item_id")
|
||||
.notNull()
|
||||
.references(() => mediaItems.id, { onDelete: "restrict" }),
|
||||
recommendedByUserId: text("recommended_by_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "restrict" }),
|
||||
reason: text("reason").notNull(),
|
||||
audienceNote: text("audience_note"),
|
||||
strength: recommendationStrengthEnum("strength").notNull().default("NORMAL"),
|
||||
containsSpoilers: boolean("contains_spoilers").notNull().default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("recommendation_workspace_media_unique").on(table.workspaceId, table.mediaItemId),
|
||||
index("recommendation_workspace_created_idx").on(table.workspaceId, table.createdAt),
|
||||
],
|
||||
);
|
||||
export const recommendationStatuses = pgTable(
|
||||
"recommendation_status",
|
||||
{
|
||||
recommendationId: text("recommendation_id")
|
||||
.notNull()
|
||||
.references(() => recommendations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
status: recommendationStatusEnum("status").notNull().default("PENDING"),
|
||||
rating: integer("rating"),
|
||||
review: text("review"),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.recommendationId, table.userId] }),
|
||||
index("recommendation_status_user_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
export const recommendationReactions = pgTable(
|
||||
"recommendation_reaction",
|
||||
{
|
||||
recommendationId: text("recommendation_id")
|
||||
.notNull()
|
||||
.references(() => recommendations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: reactionTypeEnum("type").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [primaryKey({ columns: [table.recommendationId, table.userId, table.type] })],
|
||||
);
|
||||
@@ -0,0 +1,76 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users } from "./auth";
|
||||
import { comments } from "./comments";
|
||||
import { mediaItems } from "./media";
|
||||
import {
|
||||
recommendationReactions,
|
||||
recommendations,
|
||||
recommendationStatuses,
|
||||
} from "./recommendations";
|
||||
import { workspaceMembers, workspaces } from "./workspaces";
|
||||
export const workspaceRelations = relations(workspaces, ({ many }) => ({
|
||||
members: many(workspaceMembers),
|
||||
recommendations: many(recommendations),
|
||||
}));
|
||||
export const userRelations = relations(users, ({ many }) => ({
|
||||
memberships: many(workspaceMembers),
|
||||
recommendations: many(recommendations),
|
||||
statuses: many(recommendationStatuses),
|
||||
comments: many(comments),
|
||||
}));
|
||||
export const memberRelations = relations(workspaceMembers, ({ one }) => ({
|
||||
user: one(users, { fields: [workspaceMembers.userId], references: [users.id] }),
|
||||
workspace: one(workspaces, {
|
||||
fields: [workspaceMembers.workspaceId],
|
||||
references: [workspaces.id],
|
||||
}),
|
||||
}));
|
||||
export const mediaRelations = relations(mediaItems, ({ many }) => ({
|
||||
recommendations: many(recommendations),
|
||||
}));
|
||||
export const recommendationRelations = relations(recommendations, ({ one, many }) => ({
|
||||
mediaItem: one(mediaItems, {
|
||||
fields: [recommendations.mediaItemId],
|
||||
references: [mediaItems.id],
|
||||
}),
|
||||
recommendedBy: one(users, {
|
||||
fields: [recommendations.recommendedByUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
workspace: one(workspaces, {
|
||||
fields: [recommendations.workspaceId],
|
||||
references: [workspaces.id],
|
||||
}),
|
||||
statuses: many(recommendationStatuses),
|
||||
comments: many(comments),
|
||||
reactions: many(recommendationReactions),
|
||||
}));
|
||||
export const statusRelations = relations(recommendationStatuses, ({ one }) => ({
|
||||
user: one(users, { fields: [recommendationStatuses.userId], references: [users.id] }),
|
||||
recommendation: one(recommendations, {
|
||||
fields: [recommendationStatuses.recommendationId],
|
||||
references: [recommendations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const commentRelations = relations(comments, ({ one }) => ({
|
||||
author: one(users, {
|
||||
fields: [comments.authorUserId],
|
||||
references: [users.id],
|
||||
}),
|
||||
recommendation: one(recommendations, {
|
||||
fields: [comments.recommendationId],
|
||||
references: [recommendations.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const reactionRelations = relations(recommendationReactions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [recommendationReactions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
recommendation: one(recommendations, {
|
||||
fields: [recommendationReactions.recommendationId],
|
||||
references: [recommendations.id],
|
||||
}),
|
||||
}));
|
||||
@@ -0,0 +1,53 @@
|
||||
import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { workspaceRoleEnum } from "./enums";
|
||||
import { users } from "./auth";
|
||||
export const workspaces = pgTable("workspace", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
description: text("description"),
|
||||
imageUrl: text("image_url"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
export const workspaceMembers = pgTable(
|
||||
"workspace_member",
|
||||
{
|
||||
workspaceId: text("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: workspaceRoleEnum("role").notNull().default("MEMBER"),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.workspaceId, table.userId] }),
|
||||
index("workspace_member_user_idx").on(table.userId),
|
||||
],
|
||||
);
|
||||
export const workspaceInvitations = pgTable(
|
||||
"workspace_invitation",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
workspaceId: text("workspace_id")
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
email: text("email").notNull(),
|
||||
role: workspaceRoleEnum("role").notNull().default("MEMBER"),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }),
|
||||
createdByUserId: text("created_by_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "restrict" }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("workspace_invitation_email_active_idx").on(table.workspaceId, table.email),
|
||||
index("workspace_invitation_workspace_idx").on(table.workspaceId),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,208 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { client, db } from "@/db";
|
||||
import {
|
||||
comments,
|
||||
mediaItems,
|
||||
recommendationReactions,
|
||||
recommendations,
|
||||
recommendationStatuses,
|
||||
users,
|
||||
workspaceMembers,
|
||||
workspaces,
|
||||
} from "@/db/schema";
|
||||
import { auth } from "@/lib/auth/auth";
|
||||
|
||||
const credentials = [
|
||||
{
|
||||
name: "Patch",
|
||||
email: "patch@beacon.local",
|
||||
password: "BeaconDev!Patch2026",
|
||||
role: "ADMIN" as const,
|
||||
},
|
||||
{
|
||||
name: "Rook",
|
||||
email: "rook@beacon.local",
|
||||
password: "BeaconDev!Rook2026",
|
||||
role: "MEMBER" as const,
|
||||
},
|
||||
{
|
||||
name: "Nova",
|
||||
email: "nova@beacon.local",
|
||||
password: "BeaconDev!Nova2026",
|
||||
role: "MEMBER" as const,
|
||||
},
|
||||
];
|
||||
const workspace = {
|
||||
id: "00000000-0000-4000-8000-000000000001",
|
||||
name: "El Yermo",
|
||||
slug: "el-yermo",
|
||||
description: "A small crew sharing stories worth experiencing.",
|
||||
};
|
||||
const media = [
|
||||
{
|
||||
id: "10000000-0000-4000-8000-000000000001",
|
||||
provider: "MANUAL" as const,
|
||||
externalId: "manual-blade-runner",
|
||||
type: "MOVIE" as const,
|
||||
title: "Blade Runner",
|
||||
releaseYear: 1982,
|
||||
genres: ["Science fiction", "Noir"],
|
||||
},
|
||||
{
|
||||
id: "10000000-0000-4000-8000-000000000002",
|
||||
provider: "MANUAL" as const,
|
||||
externalId: "manual-severance",
|
||||
type: "TV_SERIES" as const,
|
||||
title: "Severance",
|
||||
releaseYear: 2022,
|
||||
genres: ["Science fiction", "Drama"],
|
||||
},
|
||||
{
|
||||
id: "10000000-0000-4000-8000-000000000003",
|
||||
provider: "MANUAL" as const,
|
||||
externalId: "manual-cowboy-bebop",
|
||||
type: "ANIME" as const,
|
||||
title: "Cowboy Bebop",
|
||||
releaseYear: 1998,
|
||||
genres: ["Anime", "Space western"],
|
||||
},
|
||||
{
|
||||
id: "10000000-0000-4000-8000-000000000004",
|
||||
provider: "MANUAL" as const,
|
||||
externalId: "manual-outer-wilds",
|
||||
type: "VIDEO_GAME" as const,
|
||||
title: "Outer Wilds",
|
||||
releaseYear: 2019,
|
||||
genres: ["Exploration", "Puzzle"],
|
||||
},
|
||||
];
|
||||
|
||||
async function ensureUsers() {
|
||||
for (const credential of credentials) {
|
||||
const existing = await db.query.users.findFirst({ where: eq(users.email, credential.email) });
|
||||
if (!existing) await auth.api.signUpEmail({ body: credential });
|
||||
}
|
||||
return Promise.all(
|
||||
credentials.map(async (credential) => ({
|
||||
credential,
|
||||
user: await db.query.users.findFirst({ where: eq(users.email, credential.email) }),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const seededUsers = await ensureUsers();
|
||||
if (seededUsers.some(({ user }) => !user)) throw new Error("Development user creation failed.");
|
||||
await db
|
||||
.insert(workspaces)
|
||||
.values(workspace)
|
||||
.onConflictDoUpdate({
|
||||
target: workspaces.slug,
|
||||
set: { name: workspace.name, description: workspace.description, updatedAt: new Date() },
|
||||
});
|
||||
for (const entry of seededUsers)
|
||||
await db
|
||||
.insert(workspaceMembers)
|
||||
.values({ workspaceId: workspace.id, userId: entry.user!.id, role: entry.credential.role })
|
||||
.onConflictDoNothing();
|
||||
for (const item of media)
|
||||
await db
|
||||
.insert(mediaItems)
|
||||
.values({ ...item, metadata: {} })
|
||||
.onConflictDoUpdate({
|
||||
target: [mediaItems.provider, mediaItems.externalId],
|
||||
set: { title: item.title, releaseYear: item.releaseYear, updatedAt: new Date() },
|
||||
});
|
||||
const byEmail = Object.fromEntries(
|
||||
seededUsers.map(({ credential, user }) => [credential.email, user!.id]),
|
||||
);
|
||||
const items = [
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000001",
|
||||
mediaItemId: media[0].id,
|
||||
recommendedByUserId: byEmail["patch@beacon.local"],
|
||||
reason: "If you enjoyed Cyberpunk 2077, this is essential.",
|
||||
strength: "MUST_EXPERIENCE" as const,
|
||||
},
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000002",
|
||||
mediaItemId: media[1].id,
|
||||
recommendedByUserId: byEmail["rook@beacon.local"],
|
||||
reason: "A sharp, unsettling workplace mystery best watched with someone to debrief with.",
|
||||
strength: "HIGH" as const,
|
||||
},
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000003",
|
||||
mediaItemId: media[2].id,
|
||||
recommendedByUserId: byEmail["nova@beacon.local"],
|
||||
reason: "Style, music, and a crew you will miss when it ends.",
|
||||
strength: "HIGH" as const,
|
||||
},
|
||||
{
|
||||
id: "20000000-0000-4000-8000-000000000004",
|
||||
mediaItemId: media[3].id,
|
||||
recommendedByUserId: byEmail["patch@beacon.local"],
|
||||
reason: "Go in blind and give curiosity the controls.",
|
||||
strength: "MUST_EXPERIENCE" as const,
|
||||
},
|
||||
];
|
||||
for (const item of items)
|
||||
await db
|
||||
.insert(recommendations)
|
||||
.values({ ...item, workspaceId: workspace.id })
|
||||
.onConflictDoNothing();
|
||||
await db
|
||||
.insert(recommendationStatuses)
|
||||
.values([
|
||||
{ recommendationId: items[0].id, userId: byEmail["rook@beacon.local"], status: "INTERESTED" },
|
||||
{
|
||||
recommendationId: items[0].id,
|
||||
userId: byEmail["nova@beacon.local"],
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
rating: 9,
|
||||
},
|
||||
{
|
||||
recommendationId: items[3].id,
|
||||
userId: byEmail["rook@beacon.local"],
|
||||
status: "IN_PROGRESS",
|
||||
startedAt: new Date(),
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
await db
|
||||
.insert(recommendationReactions)
|
||||
.values([
|
||||
{ recommendationId: items[0].id, userId: byEmail["rook@beacon.local"], type: "ENDORSE" },
|
||||
{ recommendationId: items[3].id, userId: byEmail["nova@beacon.local"], type: "INTERESTED" },
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
await db
|
||||
.insert(comments)
|
||||
.values([
|
||||
{
|
||||
id: "30000000-0000-4000-8000-000000000001",
|
||||
recommendationId: items[0].id,
|
||||
authorUserId: byEmail["rook@beacon.local"],
|
||||
body: "The atmosphere has stayed with me for years.",
|
||||
containsSpoilers: false,
|
||||
},
|
||||
{
|
||||
id: "30000000-0000-4000-8000-000000000002",
|
||||
recommendationId: items[3].id,
|
||||
authorUserId: byEmail["nova@beacon.local"],
|
||||
body: "The final discovery completely reframes the journey.",
|
||||
containsSpoilers: true,
|
||||
},
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
console.info("Beacon development seed completed.");
|
||||
}
|
||||
seed()
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(async () => {
|
||||
await client.end();
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
import { db } from "./index";
|
||||
export type DatabaseTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
@@ -0,0 +1,16 @@
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { recommendations } from "@/db/schema";
|
||||
export async function listWorkspaceRecommendations(workspaceId: string) {
|
||||
return db.query.recommendations.findMany({
|
||||
where: eq(recommendations.workspaceId, workspaceId),
|
||||
orderBy: [desc(recommendations.createdAt)],
|
||||
with: { mediaItem: true, recommendedBy: true, statuses: true, comments: true, reactions: true },
|
||||
});
|
||||
}
|
||||
export async function getRecommendation(id: string) {
|
||||
return db.query.recommendations.findFirst({
|
||||
where: eq(recommendations.id, id),
|
||||
with: { mediaItem: true, recommendedBy: true, statuses: true, comments: true },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { users } from "@/db/schema";
|
||||
export async function findUserById(id: string) {
|
||||
return db.query.users.findFirst({ where: eq(users.id, id) });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { workspaceMembers, workspaces } from "@/db/schema";
|
||||
export async function findWorkspaceBySlug(slug: string) {
|
||||
return db.query.workspaces.findFirst({ where: eq(workspaces.slug, slug) });
|
||||
}
|
||||
export async function getUserWorkspaces(userId: string) {
|
||||
return db
|
||||
.select({ workspace: workspaces, role: workspaceMembers.role })
|
||||
.from(workspaceMembers)
|
||||
.innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId))
|
||||
.where(eq(workspaceMembers.userId, userId));
|
||||
}
|
||||
export async function findWorkspaceMembership(workspaceId: string, userId: string) {
|
||||
return db.query.workspaceMembers.findFirst({
|
||||
where: (member, { and, eq }) =>
|
||||
and(eq(member.workspaceId, workspaceId), eq(member.userId, userId)),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataProvider } from "../metadata-provider";
|
||||
export const anilistProvider: MetadataProvider = {
|
||||
name: "ANILIST",
|
||||
supports: (type) => type === "ANIME",
|
||||
async search() {
|
||||
return [];
|
||||
},
|
||||
async getDetails() {
|
||||
throw new Error("AniList integration is not enabled.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataProvider } from "../metadata-provider";
|
||||
export const igdbProvider: MetadataProvider = {
|
||||
name: "IGDB",
|
||||
supports: (type) => type === "VIDEO_GAME",
|
||||
async search() {
|
||||
return [];
|
||||
},
|
||||
async getDetails() {
|
||||
throw new Error("IGDB integration is not enabled.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ExternalProviderError } from "@/lib/errors/application-error";
|
||||
import { MetadataProviderRegistry } from "./metadata-provider-registry";
|
||||
import { tmdbProvider } from "./tmdb/provider";
|
||||
describe("metadata provider registry", () => {
|
||||
it("resolves a supported provider", () =>
|
||||
expect(new MetadataProviderRegistry([tmdbProvider]).get("TMDB", "MOVIE")).toBe(tmdbProvider));
|
||||
it("rejects unsupported media", () =>
|
||||
expect(() => new MetadataProviderRegistry([tmdbProvider]).get("TMDB", "ANIME")).toThrow(
|
||||
ExternalProviderError,
|
||||
));
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ExternalProviderError } from "@/lib/errors/application-error";
|
||||
import type { MetadataProvider } from "./metadata-provider";
|
||||
import type { MediaType, MetadataProviderName } from "./types";
|
||||
export class MetadataProviderRegistry {
|
||||
constructor(private readonly providers: MetadataProvider[]) {}
|
||||
get(name: MetadataProviderName, type: MediaType) {
|
||||
const provider = this.providers.find((item) => item.name === name && item.supports(type));
|
||||
if (!provider) throw new ExternalProviderError(`Provider ${name} does not support ${type}.`);
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type {
|
||||
MediaDetails,
|
||||
MediaSearchOptions,
|
||||
MediaSearchResult,
|
||||
MediaType,
|
||||
MetadataProviderName,
|
||||
} from "./types";
|
||||
export interface MetadataProvider {
|
||||
readonly name: MetadataProviderName;
|
||||
supports(type: MediaType): boolean;
|
||||
search(options: MediaSearchOptions): Promise<MediaSearchResult[]>;
|
||||
getDetails(externalId: string, type: MediaType): Promise<MediaDetails>;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataProvider } from "../metadata-provider";
|
||||
export const tmdbProvider: MetadataProvider = {
|
||||
name: "TMDB",
|
||||
supports: (type) => type === "MOVIE" || type === "TV_SERIES",
|
||||
async search() {
|
||||
return [];
|
||||
},
|
||||
async getDetails() {
|
||||
throw new Error("TMDB integration is not enabled.");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
export type MediaType = "MOVIE" | "TV_SERIES" | "ANIME" | "VIDEO_GAME";
|
||||
export type MetadataProviderName = "TMDB" | "ANILIST" | "IGDB";
|
||||
export interface MediaSearchOptions {
|
||||
query: string;
|
||||
type: MediaType;
|
||||
locale?: string;
|
||||
page?: number;
|
||||
}
|
||||
export interface MediaSearchResult {
|
||||
provider: MetadataProviderName;
|
||||
externalId: string;
|
||||
type: MediaType;
|
||||
title: string;
|
||||
originalTitle?: string;
|
||||
description?: string;
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
posterUrl?: string;
|
||||
}
|
||||
export interface MediaDetails extends MediaSearchResult {
|
||||
backdropUrl?: string;
|
||||
genres: string[];
|
||||
runtimeMinutes?: number;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
"use client";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
export const authClient = createAuthClient();
|
||||
@@ -0,0 +1,34 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { env } from "@/config/env";
|
||||
import { db } from "@/db";
|
||||
import * as schema from "@/db/schema";
|
||||
import { logger } from "@/lib/logging";
|
||||
|
||||
export const auth = betterAuth({
|
||||
appName: env.APP_NAME,
|
||||
baseURL: env.BETTER_AUTH_URL,
|
||||
secret: env.BETTER_AUTH_SECRET,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
user: schema.users,
|
||||
session: schema.sessions,
|
||||
account: schema.accounts,
|
||||
verification: schema.verifications,
|
||||
},
|
||||
}),
|
||||
emailAndPassword: { enabled: true, requireEmailVerification: false },
|
||||
advanced: {
|
||||
defaultCookieAttributes: {
|
||||
secure: env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
httpOnly: true,
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
disabled: env.NODE_ENV === "test",
|
||||
log: (level, message, ...args) =>
|
||||
logger[level === "debug" ? "debug" : "info"]({ args }, message),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "./auth";
|
||||
import {
|
||||
AuthenticationRequiredError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
} from "@/lib/errors/application-error";
|
||||
import {
|
||||
findWorkspaceBySlug,
|
||||
findWorkspaceMembership,
|
||||
} from "@/features/workspaces/repositories/workspace-repository";
|
||||
import { hasWorkspaceRole, type WorkspaceRole } from "@/lib/permissions/roles";
|
||||
|
||||
export async function getCurrentSession() {
|
||||
return auth.api.getSession({ headers: await headers() });
|
||||
}
|
||||
export async function requireUser() {
|
||||
const session = await getCurrentSession();
|
||||
if (!session?.user) throw new AuthenticationRequiredError();
|
||||
return session.user;
|
||||
}
|
||||
export async function requirePageUser() {
|
||||
try {
|
||||
return await requireUser();
|
||||
} catch {
|
||||
redirect("/login");
|
||||
}
|
||||
}
|
||||
export async function requireWorkspaceMember(slug: string) {
|
||||
const user = await requireUser();
|
||||
const workspace = await findWorkspaceBySlug(slug);
|
||||
if (!workspace) throw new NotFoundError("Workspace");
|
||||
const membership = await findWorkspaceMembership(workspace.id, user.id);
|
||||
if (!membership) throw new ForbiddenError("You are not a member of this workspace.");
|
||||
return { user, workspace, membership };
|
||||
}
|
||||
export async function requireWorkspaceRole(slug: string, role: WorkspaceRole) {
|
||||
const context = await requireWorkspaceMember(slug);
|
||||
if (!hasWorkspaceRole(context.membership.role, role)) throw new ForbiddenError();
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export class ApplicationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
export class AuthenticationRequiredError extends ApplicationError {
|
||||
constructor() {
|
||||
super("Authentication is required.", "AUTHENTICATION_REQUIRED", 401);
|
||||
}
|
||||
}
|
||||
export class ForbiddenError extends ApplicationError {
|
||||
constructor(message = "You do not have permission to perform this action.") {
|
||||
super(message, "FORBIDDEN", 403);
|
||||
}
|
||||
}
|
||||
export class NotFoundError extends ApplicationError {
|
||||
constructor(resource = "Resource") {
|
||||
super(`${resource} was not found.`, "NOT_FOUND", 404);
|
||||
}
|
||||
}
|
||||
export class ValidationError extends ApplicationError {
|
||||
constructor(message = "The submitted data is invalid.") {
|
||||
super(message, "VALIDATION_ERROR", 400);
|
||||
}
|
||||
}
|
||||
export class ConflictError extends ApplicationError {
|
||||
constructor(message = "This resource already exists.") {
|
||||
super(message, "CONFLICT", 409);
|
||||
}
|
||||
}
|
||||
export class ExternalProviderError extends ApplicationError {
|
||||
constructor(message = "The metadata provider is unavailable.") {
|
||||
super(message, "EXTERNAL_PROVIDER_ERROR", 502);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ApplicationError } from "./application-error";
|
||||
export function errorResponse(error: unknown) {
|
||||
if (error instanceof ApplicationError)
|
||||
return NextResponse.json(
|
||||
{ error: { code: error.code, message: error.message } },
|
||||
{ status: error.status },
|
||||
);
|
||||
return NextResponse.json(
|
||||
{ error: { code: "INTERNAL_ERROR", message: "An unexpected error occurred." } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import pino from "pino";
|
||||
import { env } from "@/config/env";
|
||||
export const logger = pino({
|
||||
level: env.LOG_LEVEL,
|
||||
redact: ["password", "token", "authorization", "cookie", "DATABASE_URL"],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ForbiddenError } from "@/lib/errors/application-error";
|
||||
import { hasWorkspaceRole, requireWorkspaceRoleValue } from "./roles";
|
||||
describe("workspace roles", () => {
|
||||
it("lets administrators perform member actions", () =>
|
||||
expect(hasWorkspaceRole("ADMIN", "MEMBER")).toBe(true));
|
||||
it("rejects a member administrator action", () =>
|
||||
expect(() => requireWorkspaceRoleValue("MEMBER", "ADMIN")).toThrow(ForbiddenError));
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ForbiddenError } from "@/lib/errors/application-error";
|
||||
export type WorkspaceRole = "ADMIN" | "MEMBER";
|
||||
const hierarchy: Record<WorkspaceRole, number> = { MEMBER: 0, ADMIN: 1 };
|
||||
export function hasWorkspaceRole(actual: WorkspaceRole, required: WorkspaceRole) {
|
||||
return hierarchy[actual] >= hierarchy[required];
|
||||
}
|
||||
export function requireWorkspaceRoleValue(actual: WorkspaceRole, required: WorkspaceRole) {
|
||||
if (!hasWorkspaceRole(actual, required)) throw new ForbiddenError();
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };
|
||||
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Result<never, E> => ({ ok: false, error });
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user