Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=
# Server-only configuration
DATABASE_URL=
SESSION_SECRET=
# Base64-encoded 32-byte key. Multiple keys require stable key-id:base64-key entries.
ENCRYPTION_KEY=

# Onchain configuration
Expand Down
86 changes: 86 additions & 0 deletions .github/workflows/database.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: Database

on:
pull_request:
branches:
- main
paths:
- "drizzle/**"
- "drizzle.config.ts"
- "package.json"
- "pnpm-lock.yaml"
- "src/db/**"
push:
branches:
- main
paths:
- "drizzle/**"
- "drizzle.config.ts"
- "package.json"
- "pnpm-lock.yaml"
- "src/db/**"

concurrency:
group: database-${{ github.ref }}
cancel-in-progress: false

jobs:
migration-check:
if: github.event_name == 'pull_request'
name: Migration Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 9.15.9

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate migrations
run: pnpm db:generate

- name: Verify generated migrations are committed
run: git diff --exit-code -- drizzle drizzle.config.ts src/db

migrate-production:
if: github.event_name == 'push'
name: Migrate Production
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
persist-credentials: false

- name: Setup pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
version: 9.15.9

- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Apply production migrations
run: pnpm db:migrate
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,20 +32,43 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the
- `pnpm build`: create a production build.
- `pnpm start`: run the production build.
- `pnpm lint`: run ESLint.
- `pnpm db:generate`: generate Drizzle SQL migrations from the schema.
- `pnpm db:migrate`: apply Drizzle migrations to `DATABASE_URL`.
- `pnpm db:reset:local`: drop and recreate the local `public` schema, then run migrations.
- `pnpm db:studio`: open Drizzle Studio for local database inspection.

## Stack

- Next.js App Router.
- TypeScript.
- Tailwind CSS.
- shadcn/ui.
- Neon Postgres.
- Drizzle ORM.
- RaidGuild brand color tokens.

## Security Notes

The repo is public. Do not commit real treasury addresses, DAO addresses, RPC URLs, API keys, bank data, or classified accounting records.

Use `.env.local` for local secrets. `.env.example` should contain only placeholder keys.
Drizzle CLI commands also load `.env`, with `.env.local` overriding it when present.
The app targets Neon in production, while Drizzle migrations use the standard `pg` driver so local Postgres databases work with `pnpm db:migrate`.
`pnpm db:reset:local` refuses non-localhost database URLs and protected database names, but it is still destructive for the selected local database.

`ENCRYPTION_KEY` must be a base64-encoded 32-byte key. Multiple-key rotation requires stable `key-id:base64-key` entries. To generate a local development key:

```bash
openssl rand -base64 32
```

## Database Deployment

The `Database` GitHub Actions workflow checks generated migrations on pull requests and applies committed migrations on pushes to `main`.

Production migrations require a GitHub Actions production environment secret named `DATABASE_URL`.

If Vercel is deployed through the Git integration, its production build may start at the same time as the migration workflow. For strict migration-before-deploy ordering, deploy Vercel from GitHub Actions after the migration job instead of using Vercel's automatic Git deployment.

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Expand Down
16 changes: 16 additions & 0 deletions drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";

config({ path: ".env", quiet: true });
config({ path: ".env.local", override: true, quiet: true });

export default defineConfig({
dialect: "postgresql",
schema: "./src/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "",
},
strict: true,
verbose: true,
});
147 changes: 147 additions & 0 deletions drizzle/0000_gorgeous_mephisto.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";--> statement-breakpoint
CREATE TYPE "public"."audit_action" AS ENUM('create', 'update', 'delete', 'import', 'classify', 'publish', 'reopen', 'grant_role', 'revoke_role');--> statement-breakpoint
Comment thread
ECWireless marked this conversation as resolved.
CREATE TYPE "public"."entity_type" AS ENUM('client', 'provider', 'subcontractor');--> statement-breakpoint
CREATE TYPE "public"."ledger_category" AS ENUM('raid_revenue', 'subcontractor_payout', 'provider_expense', 'member_dues', 'ragequit', 'treasury_transfer', 'uncategorized');--> statement-breakpoint
CREATE TYPE "public"."ledger_source" AS ENUM('main_safe', 'side_vault', 'manual', 'bank_csv', 'dao_proposal');--> statement-breakpoint
CREATE TYPE "public"."quarter_status" AS ENUM('draft', 'ready_for_review', 'published', 'reopened');--> statement-breakpoint
CREATE TYPE "public"."treasury_account_type" AS ENUM('main_safe', 'side_vault');--> statement-breakpoint
CREATE TYPE "public"."verification_status" AS ENUM('verified', 'unverified');--> statement-breakpoint
CREATE TABLE "app_users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"wallet_address" text NOT NULL,
"display_name_encrypted" jsonb,
"last_seen_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 "audit_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"actor_user_id" uuid,
"actor_wallet_address" text,
"action" "audit_action" NOT NULL,
"subject_table" text NOT NULL,
"subject_id" uuid,
"quarter_id" uuid,
"summary" text NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "cleric_roles" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"wallet_address" text NOT NULL,
"granted_by_user_id" uuid,
"revoked_at" timestamp with time zone,
"revoked_by_user_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "entities" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"type" "entity_type" NOT NULL,
"name_encrypted" jsonb NOT NULL,
"website_encrypted" jsonb,
"notes_encrypted" jsonb,
"is_member" 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
);
--> statement-breakpoint
CREATE TABLE "entity_addresses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"entity_id" uuid NOT NULL,
"address" text NOT NULL,
"chain_id" integer,
"label_encrypted" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ledger_entries" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"quarter_id" uuid,
"source" "ledger_source" NOT NULL,
"category" "ledger_category" DEFAULT 'uncategorized' NOT NULL,
"verification_status" "verification_status" DEFAULT 'verified' NOT NULL,
"occurred_at" timestamp with time zone NOT NULL,
"chain_id" integer,
"tx_hash" text,
"treasury_account_id" uuid,
"asset_symbol" text NOT NULL,
"asset_amount" numeric(36, 18) NOT NULL,
"usd_amount" numeric(18, 2) NOT NULL,
"counterparty_entity_id" uuid,
"raid_id" uuid,
"notes_encrypted" jsonb,
"source_metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "quarters" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"label" text NOT NULL,
"year" integer NOT NULL,
"quarter" integer NOT NULL,
"starts_on" date NOT NULL,
"ends_on" date NOT NULL,
"status" "quarter_status" DEFAULT 'draft' NOT NULL,
"published_at" timestamp with time zone,
"reopened_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 "raids" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"client_entity_id" uuid NOT NULL,
"name_encrypted" jsonb NOT NULL,
"notes_encrypted" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "treasury_accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name_encrypted" jsonb NOT NULL,
"address" text NOT NULL,
"chain_id" integer NOT NULL,
"type" "treasury_account_type" NOT NULL,
"is_dao_controlled" boolean DEFAULT true NOT NULL,
"notes_encrypted" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_actor_user_id_app_users_id_fk" FOREIGN KEY ("actor_user_id") REFERENCES "public"."app_users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "audit_events" ADD CONSTRAINT "audit_events_quarter_id_quarters_id_fk" FOREIGN KEY ("quarter_id") REFERENCES "public"."quarters"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cleric_roles" ADD CONSTRAINT "cleric_roles_granted_by_user_id_app_users_id_fk" FOREIGN KEY ("granted_by_user_id") REFERENCES "public"."app_users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cleric_roles" ADD CONSTRAINT "cleric_roles_revoked_by_user_id_app_users_id_fk" FOREIGN KEY ("revoked_by_user_id") REFERENCES "public"."app_users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entity_addresses" ADD CONSTRAINT "entity_addresses_entity_id_entities_id_fk" FOREIGN KEY ("entity_id") REFERENCES "public"."entities"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_quarter_id_quarters_id_fk" FOREIGN KEY ("quarter_id") REFERENCES "public"."quarters"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_treasury_account_id_treasury_accounts_id_fk" FOREIGN KEY ("treasury_account_id") REFERENCES "public"."treasury_accounts"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_counterparty_entity_id_entities_id_fk" FOREIGN KEY ("counterparty_entity_id") REFERENCES "public"."entities"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "ledger_entries" ADD CONSTRAINT "ledger_entries_raid_id_raids_id_fk" FOREIGN KEY ("raid_id") REFERENCES "public"."raids"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "raids" ADD CONSTRAINT "raids_client_entity_id_entities_id_fk" FOREIGN KEY ("client_entity_id") REFERENCES "public"."entities"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "app_users_wallet_address_unique" ON "app_users" USING btree ("wallet_address");--> statement-breakpoint
CREATE INDEX "audit_events_subject_idx" ON "audit_events" USING btree ("subject_table","subject_id");--> statement-breakpoint
CREATE INDEX "audit_events_actor_user_id_idx" ON "audit_events" USING btree ("actor_user_id");--> statement-breakpoint
CREATE INDEX "audit_events_quarter_id_idx" ON "audit_events" USING btree ("quarter_id");--> statement-breakpoint
CREATE INDEX "audit_events_created_at_idx" ON "audit_events" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "cleric_roles_wallet_address_idx" ON "cleric_roles" USING btree ("wallet_address");--> statement-breakpoint
CREATE INDEX "cleric_roles_active_idx" ON "cleric_roles" USING btree ("wallet_address","revoked_at");--> statement-breakpoint
CREATE INDEX "entities_type_idx" ON "entities" USING btree ("type");--> statement-breakpoint
CREATE INDEX "entity_addresses_entity_id_idx" ON "entity_addresses" USING btree ("entity_id");--> statement-breakpoint
CREATE INDEX "entity_addresses_address_idx" ON "entity_addresses" USING btree ("address");--> statement-breakpoint
CREATE INDEX "ledger_entries_quarter_id_idx" ON "ledger_entries" USING btree ("quarter_id");--> statement-breakpoint
CREATE INDEX "ledger_entries_category_idx" ON "ledger_entries" USING btree ("category");--> statement-breakpoint
CREATE INDEX "ledger_entries_tx_hash_idx" ON "ledger_entries" USING btree ("tx_hash");--> statement-breakpoint
CREATE INDEX "ledger_entries_raid_id_idx" ON "ledger_entries" USING btree ("raid_id");--> statement-breakpoint
CREATE INDEX "ledger_entries_occurred_at_idx" ON "ledger_entries" USING btree ("occurred_at");--> statement-breakpoint
CREATE UNIQUE INDEX "quarters_year_quarter_unique" ON "quarters" USING btree ("year","quarter");--> statement-breakpoint
CREATE INDEX "quarters_status_idx" ON "quarters" USING btree ("status");--> statement-breakpoint
CREATE INDEX "raids_client_entity_id_idx" ON "raids" USING btree ("client_entity_id");--> statement-breakpoint
CREATE UNIQUE INDEX "treasury_accounts_chain_address_unique" ON "treasury_accounts" USING btree ("chain_id","address");--> statement-breakpoint
CREATE INDEX "treasury_accounts_type_idx" ON "treasury_accounts" USING btree ("type");
46 changes: 46 additions & 0 deletions drizzle/0001_damp_wiccan.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto";--> statement-breakpoint
DROP INDEX "app_users_wallet_address_unique";--> statement-breakpoint
DROP INDEX "cleric_roles_wallet_address_idx";--> statement-breakpoint
DROP INDEX "cleric_roles_active_idx";--> statement-breakpoint
CREATE UNIQUE INDEX "app_users_wallet_address_unique" ON "app_users" USING btree (lower("wallet_address"));--> statement-breakpoint
CREATE INDEX "cleric_roles_wallet_address_idx" ON "cleric_roles" USING btree (lower("wallet_address"));--> statement-breakpoint
CREATE INDEX "cleric_roles_active_idx" ON "cleric_roles" USING btree (lower("wallet_address"),"revoked_at");--> statement-breakpoint
CREATE OR REPLACE FUNCTION "public"."set_updated_at"()
RETURNS trigger AS $$
BEGIN
NEW."updated_at" = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;--> statement-breakpoint
CREATE TRIGGER "app_users_set_updated_at"
BEFORE UPDATE ON "app_users"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "cleric_roles_set_updated_at"
BEFORE UPDATE ON "cleric_roles"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "entities_set_updated_at"
BEFORE UPDATE ON "entities"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "entity_addresses_set_updated_at"
BEFORE UPDATE ON "entity_addresses"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "ledger_entries_set_updated_at"
BEFORE UPDATE ON "ledger_entries"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "quarters_set_updated_at"
BEFORE UPDATE ON "quarters"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "raids_set_updated_at"
BEFORE UPDATE ON "raids"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();--> statement-breakpoint
CREATE TRIGGER "treasury_accounts_set_updated_at"
BEFORE UPDATE ON "treasury_accounts"
FOR EACH ROW
EXECUTE FUNCTION "public"."set_updated_at"();
Loading
Loading