UPD: initial architecture
CI / quality (push) Waiting to run

This commit is contained in:
Uklonil
2026-07-27 10:23:50 +02:00
parent 63fc0e6771
commit 3aeaed0af2
108 changed files with 11103 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.next
.git
.env
coverage
playwright-report
test-results
+8
View File
@@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
+11
View File
@@ -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=
+17
View File
@@ -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
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
.env
.env.local
coverage
playwright-report
test-results
*.tsbuildinfo
+4
View File
@@ -0,0 +1,4 @@
.next
node_modules
pnpm-lock.yaml
drizzle/migrations
+23
View File
@@ -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
View File
@@ -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"]
+31
View File
@@ -1,2 +1,33 @@
# Beacon # 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.
+28
View File
@@ -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:
+14
View File
@@ -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.
+9
View File
@@ -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.
+11
View File
@@ -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,
});
+205
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1785139678448,
"tag": "0000_funny_paibok",
"breakpoints": true
}
]
}
+9
View File
@@ -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"]),
]);
+14
View File
@@ -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*"] };
+6
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;
+70
View File
@@ -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"
}
}
+9
View File
@@ -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"] } }],
});
+7214
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
const config = { plugins: { tailwindcss: {}, autoprefixer: {} } };
export default config;
+2
View File
@@ -0,0 +1,2 @@
const config = { plugins: ["prettier-plugin-tailwindcss"], printWidth: 100, trailingComma: "all" };
export default config;
+4
View File
@@ -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.
+4
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
node ./scripts/wait-for-database.js
node ./scripts/migrate.js
exec node server.js
+14
View File
@@ -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);
});
+22
View File
@@ -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);
});
+17
View File
@@ -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));
}
}
+11
View File
@@ -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>
);
}
+11
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+11
View File
@@ -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>
</>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth/auth";
export const { GET, POST } = toNextJsHandler(auth);
+26
View File
@@ -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 },
);
}
}
+9
View File
@@ -0,0 +1,9 @@
"use client";
import { ErrorState } from "@/components/states/error-state";
export default function Error() {
return (
<main className="p-8">
<ErrorState />
</main>
);
}
+10
View File
@@ -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>
);
}
+49
View File
@@ -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;
}
}
+19
View File
@@ -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>
);
}
+8
View File
@@ -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>
);
}
+12
View File
@@ -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>
);
}
+9
View File
@@ -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");
}
+56
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+20
View File
@@ -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>
);
}
+19
View File
@@ -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>
);
}
+18
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+6
View File
@@ -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} />;
}
+27
View File
@@ -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} />;
}
+13
View File
@@ -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}
/>
);
}
+13
View File
@@ -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());
});
+21
View File
@@ -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);
+9
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
export const siteConfig = {
name: "Beacon",
description: "Discover through people.",
locale: "en",
} as const;
+8
View File
@@ -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 };
+13
View File
@@ -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(),
});
+56
View File
@@ -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(),
});
+23
View File
@@ -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),
],
);
+23
View File
@@ -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"]);
+9
View File
@@ -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";
+28
View File
@@ -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),
],
);
+22
View File
@@ -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,
),
],
);
+75
View File
@@ -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] })],
);
+76
View File
@@ -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],
}),
}));
+53
View File
@@ -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),
],
);
+208
View File
@@ -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();
});
+2
View File
@@ -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.");
},
};
+25
View File
@@ -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>;
}
+3
View File
@@ -0,0 +1,3 @@
"use client";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();
+34
View File
@@ -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),
},
});
+42
View File
@@ -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;
}
+40
View File
@@ -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);
}
}
+13
View File
@@ -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 },
);
}
+6
View File
@@ -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"],
});
+9
View File
@@ -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));
});
+9
View File
@@ -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();
}
+3
View File
@@ -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