This commit is contained in:
@@ -11,3 +11,8 @@ BETTER_AUTH_SECRET=""
|
|||||||
# https://www.better-auth.com/docs/authentication/github
|
# https://www.better-auth.com/docs/authentication/github
|
||||||
GITHUB_CLIENT_ID=""
|
GITHUB_CLIENT_ID=""
|
||||||
GITHUB_CLIENT_SECRET=""
|
GITHUB_CLIENT_SECRET=""
|
||||||
|
|
||||||
|
# GitHub App
|
||||||
|
GITHUB_APP_ID=""
|
||||||
|
GITHUB_APP_SLUG=""
|
||||||
|
GITHUB_APP_SETUP_URL=""
|
||||||
|
|||||||
16
docs/evaluation.md
Normal file
16
docs/evaluation.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Evaluering
|
||||||
|
|
||||||
|
## Hvad fungerede godt
|
||||||
|
- SvelteKit gjorde det hurtigt at samle UI og backend i samme repo.
|
||||||
|
- Login og beskyttede routes gav en klar brugerflow.
|
||||||
|
- Projekter og tasks kan udvides uden at ændre arkitekturen meget.
|
||||||
|
|
||||||
|
## Hvad var udfordrende
|
||||||
|
- Rigtig GitHub/Gitea sync kræver provider-specifik auth og API-håndtering.
|
||||||
|
- Realtidsopdateringer skal afstemmes med deploy-miljø og connection limits.
|
||||||
|
- Progressberegning skal være konsistent, når tasks ændres samtidigt.
|
||||||
|
|
||||||
|
## Hvordan kan det bruges professionelt
|
||||||
|
- Som intern status- og opgaveportal.
|
||||||
|
- Som udviklerdashboard til issues og sprint-overblik.
|
||||||
|
- Som fundament for et teamværktøj med integrationer og live status.
|
||||||
30
docs/technical-decisions.md
Normal file
30
docs/technical-decisions.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Tekniske Valg
|
||||||
|
|
||||||
|
## Hvad jeg byggede
|
||||||
|
En SvelteKit-baseret produktivitetsapp med login, projekter, tasks, issue-overblik og live opdateringskanal.
|
||||||
|
|
||||||
|
## Hvorfor SvelteKit
|
||||||
|
- Server `load` gør det enkelt at hente data tæt på routen.
|
||||||
|
- Form actions passer godt til mutationer uden tung klientlogik.
|
||||||
|
- SSR giver hurtig første render og bedre default UX.
|
||||||
|
- SSE passer til lette live-opdateringer uden fuld websocket-kompleksitet.
|
||||||
|
|
||||||
|
## Hvordan det virker i praksis
|
||||||
|
- Brugeren logger ind.
|
||||||
|
- Dashboardet henter projekter, tasks og issues server-side.
|
||||||
|
- Brugeren kan oprette projekter og tasks via actions.
|
||||||
|
- Projekter kan opdatere status og beregnet progress.
|
||||||
|
- En SSE-endpoint kan sende opdateringer til klienten.
|
||||||
|
|
||||||
|
## Hvad der virkede godt
|
||||||
|
- SvelteKit gjorde dataflowet kort og tydeligt.
|
||||||
|
- Server actions holdt mutationerne samlede.
|
||||||
|
- Drizzle passede godt til schema-drevet udvikling.
|
||||||
|
|
||||||
|
## Hvad der var udfordrende
|
||||||
|
- Realtime sync fra GitHub/Gitea kræver API-specifik integration.
|
||||||
|
- Progress bør helst beregnes robust i databasen eller via en service.
|
||||||
|
- SSE er nemt til énvejskommunikation, men websocket kan blive nødvendig senere.
|
||||||
|
|
||||||
|
## Professionel brug
|
||||||
|
Løsningen kan bruges som intern project tracker, support-overblik eller developer dashboard med integrerede issues og statusopdateringer.
|
||||||
19
docs/weekly-plan.md
Normal file
19
docs/weekly-plan.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Weekly Plan
|
||||||
|
|
||||||
|
## Uge 1
|
||||||
|
- Planlæg et lille MVP-projekt i SvelteKit.
|
||||||
|
- Implementer login, dashboard, projekter og opgaver.
|
||||||
|
- Dokumenter hvorfor SvelteKit passer til SSR, form actions og realtime opdateringer.
|
||||||
|
|
||||||
|
## Uge 2
|
||||||
|
- Tilføj issue-sync fra GitHub eller Gitea.
|
||||||
|
- Arbejd med live opdateringer via SSE.
|
||||||
|
- Dokumenter de tekniske valg undervejs.
|
||||||
|
- Beskriv hvordan teknologien fungerer i praksis.
|
||||||
|
- Vurder hvad der fungerede godt, og hvad der var udfordrende.
|
||||||
|
- Forklar hvordan løsningen kan bruges professionelt.
|
||||||
|
|
||||||
|
## Leverancer
|
||||||
|
- Kort teknisk dokumentation.
|
||||||
|
- Kildekode med projekter, tasks og issue-overblik.
|
||||||
|
- En evaluering af styrker og begrænsninger.
|
||||||
33
drizzle/0001_kind_jamie_braddock.sql
Normal file
33
drizzle/0001_kind_jamie_braddock.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
CREATE TABLE "issue" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"project_id" integer NOT NULL,
|
||||||
|
"provider" text NOT NULL,
|
||||||
|
"external_id" text NOT NULL,
|
||||||
|
"url" text NOT NULL,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"state" text NOT NULL,
|
||||||
|
"labels" text DEFAULT '[]' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "project" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"owner_id" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text DEFAULT '' NOT NULL,
|
||||||
|
"status" text DEFAULT 'planning' NOT NULL,
|
||||||
|
"progress" integer DEFAULT 0 NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "project_id" integer NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "description" text DEFAULT '' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "status" text DEFAULT 'todo' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "due_date" timestamp;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD COLUMN "updated_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "issue" ADD CONSTRAINT "issue_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "project" ADD CONSTRAINT "project_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "task" ADD CONSTRAINT "task_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
28
drizzle/0002_eager_shen.sql
Normal file
28
drizzle/0002_eager_shen.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE "github_installation" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"installation_id" text NOT NULL,
|
||||||
|
"account_login" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "github_installation_installation_id_unique" UNIQUE("installation_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "github_repository_link" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"project_id" integer NOT NULL,
|
||||||
|
"installation_id" integer NOT NULL,
|
||||||
|
"owner" text NOT NULL,
|
||||||
|
"repo" text NOT NULL,
|
||||||
|
"full_name" text NOT NULL,
|
||||||
|
"default_branch" text DEFAULT 'main' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "github_repository_link_full_name_unique" UNIQUE("full_name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "github_installation" ADD CONSTRAINT "github_installation_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "github_repository_link" ADD CONSTRAINT "github_repository_link_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "github_repository_link" ADD CONSTRAINT "github_repository_link_installation_id_github_installation_id_fk" FOREIGN KEY ("installation_id") REFERENCES "public"."github_installation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "github_installation_installation_id_idx" ON "github_installation" USING btree ("installation_id");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "github_repository_link_project_id_idx" ON "github_repository_link" USING btree ("project_id");
|
||||||
630
drizzle/meta/0001_snapshot.json
Normal file
630
drizzle/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,630 @@
|
|||||||
|
{
|
||||||
|
"id": "c6ca10a5-c791-4bd8-b5ab-a9e4aeac4f6b",
|
||||||
|
"prevId": "9b42afc7-ca3d-4f87-b3d1-3ec8549767c5",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.issue": {
|
||||||
|
"name": "issue",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"name": "labels",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"issue_project_id_project_id_fk": {
|
||||||
|
"name": "issue_project_id_project_id_fk",
|
||||||
|
"tableFrom": "issue",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.project": {
|
||||||
|
"name": "project",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'planning'"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"project_owner_id_user_id_fk": {
|
||||||
|
"name": "project_owner_id_user_id_fk",
|
||||||
|
"tableFrom": "project",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.task": {
|
||||||
|
"name": "task",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'todo'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"due_date": {
|
||||||
|
"name": "due_date",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_project_id_project_id_fk": {
|
||||||
|
"name": "task_project_id_project_id_fk",
|
||||||
|
"tableFrom": "task",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
840
drizzle/meta/0002_snapshot.json
Normal file
840
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,840 @@
|
|||||||
|
{
|
||||||
|
"id": "924d9707-48ad-4091-82d5-4322719e0ef0",
|
||||||
|
"prevId": "c6ca10a5-c791-4bd8-b5ab-a9e4aeac4f6b",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.github_installation": {
|
||||||
|
"name": "github_installation",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"installation_id": {
|
||||||
|
"name": "installation_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_login": {
|
||||||
|
"name": "account_login",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"github_installation_installation_id_idx": {
|
||||||
|
"name": "github_installation_installation_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "installation_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"github_installation_user_id_user_id_fk": {
|
||||||
|
"name": "github_installation_user_id_user_id_fk",
|
||||||
|
"tableFrom": "github_installation",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"github_installation_installation_id_unique": {
|
||||||
|
"name": "github_installation_installation_id_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"installation_id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.github_repository_link": {
|
||||||
|
"name": "github_repository_link",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"installation_id": {
|
||||||
|
"name": "installation_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner": {
|
||||||
|
"name": "owner",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"repo": {
|
||||||
|
"name": "repo",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"full_name": {
|
||||||
|
"name": "full_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"default_branch": {
|
||||||
|
"name": "default_branch",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'main'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"github_repository_link_project_id_idx": {
|
||||||
|
"name": "github_repository_link_project_id_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "project_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"github_repository_link_project_id_project_id_fk": {
|
||||||
|
"name": "github_repository_link_project_id_project_id_fk",
|
||||||
|
"tableFrom": "github_repository_link",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"github_repository_link_installation_id_github_installation_id_fk": {
|
||||||
|
"name": "github_repository_link_installation_id_github_installation_id_fk",
|
||||||
|
"tableFrom": "github_repository_link",
|
||||||
|
"tableTo": "github_installation",
|
||||||
|
"columnsFrom": [
|
||||||
|
"installation_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"github_repository_link_full_name_unique": {
|
||||||
|
"name": "github_repository_link_full_name_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"full_name"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.issue": {
|
||||||
|
"name": "issue",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"name": "provider",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"external_id": {
|
||||||
|
"name": "external_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"url": {
|
||||||
|
"name": "url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"name": "state",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"name": "labels",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"issue_project_id_project_id_fk": {
|
||||||
|
"name": "issue_project_id_project_id_fk",
|
||||||
|
"tableFrom": "issue",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.project": {
|
||||||
|
"name": "project",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"owner_id": {
|
||||||
|
"name": "owner_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'planning'"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"name": "progress",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"project_owner_id_user_id_fk": {
|
||||||
|
"name": "project_owner_id_user_id_fk",
|
||||||
|
"tableFrom": "project",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"owner_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.task": {
|
||||||
|
"name": "task",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"project_id": {
|
||||||
|
"name": "project_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"name": "description",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "''"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'todo'"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"due_date": {
|
||||||
|
"name": "due_date",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"task_project_id_project_id_fk": {
|
||||||
|
"name": "task_project_id_project_id_fk",
|
||||||
|
"tableFrom": "task",
|
||||||
|
"tableTo": "project",
|
||||||
|
"columnsFrom": [
|
||||||
|
"project_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"account_userId_idx": {
|
||||||
|
"name": "account_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"session_userId_idx": {
|
||||||
|
"name": "session_userId_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"verification_identifier_idx": {
|
||||||
|
"name": "verification_identifier_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "identifier",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
"when": 1779102031969,
|
"when": 1779102031969,
|
||||||
"tag": "0000_sturdy_harrier",
|
"tag": "0000_sturdy_harrier",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779806328935,
|
||||||
|
"tag": "0001_kind_jamie_braddock",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1779807906067,
|
||||||
|
"tag": "0002_eager_shen",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,11 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { db } from '$lib/server/db';
|
import { db } from '$lib/server/db';
|
||||||
|
|
||||||
|
if (!env.ORIGIN) throw new Error('ORIGIN is not set');
|
||||||
|
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
|
||||||
|
if (!env.GITHUB_CLIENT_ID) throw new Error('GITHUB_CLIENT_ID is not set');
|
||||||
|
if (!env.GITHUB_CLIENT_SECRET) throw new Error('GITHUB_CLIENT_SECRET is not set');
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: env.ORIGIN,
|
baseURL: env.ORIGIN,
|
||||||
secret: env.BETTER_AUTH_SECRET,
|
secret: env.BETTER_AUTH_SECRET,
|
||||||
|
|||||||
@@ -1,9 +1,140 @@
|
|||||||
import { pgTable, serial, integer, text } from 'drizzle-orm/pg-core';
|
import { relations } from 'drizzle-orm';
|
||||||
|
import { pgTable, serial, integer, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
|
import { user } from './auth.schema';
|
||||||
|
|
||||||
|
export const projectStatus = ['planning', 'active', 'paused', 'done'] as const;
|
||||||
|
export const taskStatus = ['todo', 'doing', 'blocked', 'done'] as const;
|
||||||
|
export const issueSource = ['github', 'gitea'] as const;
|
||||||
|
|
||||||
|
export const githubInstallation = pgTable(
|
||||||
|
'github_installation',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
installationId: text('installation_id').notNull().unique(),
|
||||||
|
accountLogin: text('account_login').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('github_installation_installation_id_idx').on(table.installationId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const githubRepositoryLink = pgTable(
|
||||||
|
'github_repository_link',
|
||||||
|
{
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
projectId: integer('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => project.id, { onDelete: 'cascade' }),
|
||||||
|
installationId: integer('installation_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => githubInstallation.id, { onDelete: 'cascade' }),
|
||||||
|
owner: text('owner').notNull(),
|
||||||
|
repo: text('repo').notNull(),
|
||||||
|
fullName: text('full_name').notNull().unique(),
|
||||||
|
defaultBranch: text('default_branch').notNull().default('main'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
},
|
||||||
|
(table) => [uniqueIndex('github_repository_link_project_id_idx').on(table.projectId)]
|
||||||
|
);
|
||||||
|
|
||||||
export const task = pgTable('task', {
|
export const task = pgTable('task', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
|
projectId: integer('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => project.id, { onDelete: 'cascade' }),
|
||||||
title: text('title').notNull(),
|
title: text('title').notNull(),
|
||||||
priority: integer('priority').notNull().default(1)
|
description: text('description').notNull().default(''),
|
||||||
|
status: text('status', { enum: taskStatus }).notNull().default('todo'),
|
||||||
|
priority: integer('priority').notNull().default(1),
|
||||||
|
dueDate: timestamp('due_date'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const project = pgTable('project', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
ownerId: text('owner_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: 'cascade' }),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description').notNull().default(''),
|
||||||
|
status: text('status', { enum: projectStatus }).notNull().default('planning'),
|
||||||
|
progress: integer('progress').notNull().default(0),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const issue = pgTable('issue', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
projectId: integer('project_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => project.id, { onDelete: 'cascade' }),
|
||||||
|
provider: text('provider', { enum: issueSource }).notNull(),
|
||||||
|
externalId: text('external_id').notNull(),
|
||||||
|
url: text('url').notNull(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
state: text('state').notNull(),
|
||||||
|
labels: text('labels').notNull().default('[]'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at')
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => new Date())
|
||||||
|
.notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const githubInstallationRelations = relations(githubInstallation, ({ one, many }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [githubInstallation.userId],
|
||||||
|
references: [user.id]
|
||||||
|
}),
|
||||||
|
repositories: many(githubRepositoryLink)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const githubRepositoryLinkRelations = relations(githubRepositoryLink, ({ one }) => ({
|
||||||
|
project: one(project, {
|
||||||
|
fields: [githubRepositoryLink.projectId],
|
||||||
|
references: [project.id]
|
||||||
|
}),
|
||||||
|
installation: one(githubInstallation, {
|
||||||
|
fields: [githubRepositoryLink.installationId],
|
||||||
|
references: [githubInstallation.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const projectRelations = relations(project, ({ many }) => ({
|
||||||
|
tasks: many(task),
|
||||||
|
issues: many(issue),
|
||||||
|
githubRepositoryLinks: many(githubRepositoryLink)
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const taskRelations = relations(task, ({ one }) => ({
|
||||||
|
project: one(project, {
|
||||||
|
fields: [task.projectId],
|
||||||
|
references: [project.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const issueRelations = relations(issue, ({ one }) => ({
|
||||||
|
project: one(project, {
|
||||||
|
fields: [issue.projectId],
|
||||||
|
references: [project.id]
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
export * from './auth.schema';
|
export * from './auth.schema';
|
||||||
|
|||||||
16
src/lib/server/events.ts
Normal file
16
src/lib/server/events.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
const listeners = new Set<ReadableStreamDefaultController<string>>();
|
||||||
|
|
||||||
|
export function subscribe(controller: ReadableStreamDefaultController<string>) {
|
||||||
|
listeners.add(controller);
|
||||||
|
return () => listeners.delete(controller);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publish(message: string) {
|
||||||
|
for (const controller of listeners) {
|
||||||
|
try {
|
||||||
|
controller.enqueue(`data: ${message}\n\n`);
|
||||||
|
} catch {
|
||||||
|
listeners.delete(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/lib/server/github-app.ts
Normal file
172
src/lib/server/github-app.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { createHmac, createPrivateKey, createSign } from 'node:crypto';
|
||||||
|
|
||||||
|
export type GitHubRepository = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
full_name: string;
|
||||||
|
default_branch: string;
|
||||||
|
owner: { login: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type InstallState = {
|
||||||
|
redirect: string;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function requireEnv(name: string) {
|
||||||
|
switch (name) {
|
||||||
|
case 'GITHUB_APP_ID':
|
||||||
|
if (!env.GITHUB_APP_ID) throw new Error('GITHUB_APP_ID is not set');
|
||||||
|
return env.GITHUB_APP_ID;
|
||||||
|
case 'GITHUB_APP_SLUG':
|
||||||
|
if (!env.GITHUB_APP_SLUG) throw new Error('GITHUB_APP_SLUG is not set');
|
||||||
|
return env.GITHUB_APP_SLUG;
|
||||||
|
case 'GITHUB_APP_PRIVATE_KEY':
|
||||||
|
if (!env.GITHUB_APP_PRIVATE_KEY) throw new Error('GITHUB_APP_PRIVATE_KEY is not set');
|
||||||
|
return env.GITHUB_APP_PRIVATE_KEY;
|
||||||
|
case 'BETTER_AUTH_SECRET':
|
||||||
|
if (!env.BETTER_AUTH_SECRET) throw new Error('BETTER_AUTH_SECRET is not set');
|
||||||
|
return env.BETTER_AUTH_SECRET;
|
||||||
|
default:
|
||||||
|
throw new Error(`${name} is not supported`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrivateKey(key: string) {
|
||||||
|
return key.includes('-----BEGIN') ? key.replace(/\\n/g, '\n') : Buffer.from(key, 'base64').toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlJson(value: unknown) {
|
||||||
|
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
function signState(payload: InstallState, secret: string) {
|
||||||
|
const serialized = base64urlJson(payload);
|
||||||
|
const signature = createHmac('sha256', secret).update(serialized).digest('base64url');
|
||||||
|
return `${serialized}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyState(state: string, secret: string) {
|
||||||
|
const [payload, signature] = state.split('.');
|
||||||
|
if (!payload || !signature) return null;
|
||||||
|
const expected = createHmac('sha256', secret).update(payload).digest('base64url');
|
||||||
|
if (expected !== signature) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as InstallState;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGitHubInstallUrl(input: { redirect?: string; userId: string }) {
|
||||||
|
const slug = requireEnv('GITHUB_APP_SLUG');
|
||||||
|
const secret = requireEnv('BETTER_AUTH_SECRET');
|
||||||
|
const redirect = input.redirect ?? '/integrations';
|
||||||
|
const base = new URL(`https://github.com/apps/${slug}/installations/new`);
|
||||||
|
base.searchParams.set('state', signState({ redirect, userId: input.userId }, secret));
|
||||||
|
return base.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeInstallState(state?: string) {
|
||||||
|
if (!state) return { redirect: '/integrations' };
|
||||||
|
const secret = requireEnv('BETTER_AUTH_SECRET');
|
||||||
|
const parsed = verifyState(state, secret);
|
||||||
|
if (!parsed) return { redirect: '/integrations' };
|
||||||
|
return { redirect: parsed.redirect || '/integrations', userId: parsed.userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGitHubAppJwt() {
|
||||||
|
const appId = requireEnv('GITHUB_APP_ID');
|
||||||
|
const privateKey = requireEnv('GITHUB_APP_PRIVATE_KEY');
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const header = base64urlJson({ alg: 'RS256', typ: 'JWT' });
|
||||||
|
const payload = base64urlJson({ iat: now - 60, exp: now + 9 * 60, iss: appId });
|
||||||
|
const signer = createSign('RSA-SHA256');
|
||||||
|
signer.update(`${header}.${payload}`);
|
||||||
|
signer.end();
|
||||||
|
const key = createPrivateKey(normalizePrivateKey(privateKey));
|
||||||
|
const signature = signer.sign(key, 'base64url');
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubAppRequest(path: string, init: RequestInit = {}) {
|
||||||
|
const response = await fetch(`https://api.github.com${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
accept: 'application/vnd.github+json',
|
||||||
|
'x-github-api-version': '2022-11-28',
|
||||||
|
...(init.headers ?? {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub App request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitHubInstallation(installationId: string) {
|
||||||
|
const response = await githubAppRequest(`/app/installations/${installationId}`, {
|
||||||
|
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
|
||||||
|
});
|
||||||
|
return (await response.json()) as {
|
||||||
|
id: number;
|
||||||
|
account?: { login?: string };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitHubInstallationAccessToken(installationId: string) {
|
||||||
|
const response = await githubAppRequest(`/app/installations/${installationId}/access_tokens`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${createGitHubAppJwt()}` }
|
||||||
|
});
|
||||||
|
return (await response.json()) as { token: string; expires_at: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGitHubInstallationRepositories(installationId: string) {
|
||||||
|
const { token } = await getGitHubInstallationAccessToken(installationId);
|
||||||
|
const response = await fetch('https://api.github.com/installation/repositories?per_page=100', {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/vnd.github+json',
|
||||||
|
'x-github-api-version': '2022-11-28',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub repository list failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { repositories: GitHubRepository[] };
|
||||||
|
return data.repositories;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listGitHubRepositoryIssues(input: { installationId: string; owner: string; repo: string }) {
|
||||||
|
const { token } = await getGitHubInstallationAccessToken(input.installationId);
|
||||||
|
const response = await fetch(
|
||||||
|
`https://api.github.com/repos/${encodeURIComponent(input.owner)}/${encodeURIComponent(input.repo)}/issues?state=all&per_page=100`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
accept: 'application/vnd.github+json',
|
||||||
|
'x-github-api-version': '2022-11-28',
|
||||||
|
authorization: `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GitHub issue list failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await response.json()) as Array<{
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: 'open' | 'closed';
|
||||||
|
html_url: string;
|
||||||
|
labels: Array<{ name: string }>;
|
||||||
|
pull_request?: unknown;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
72
src/routes/+page.server.ts
Normal file
72
src/routes/+page.server.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { project, task, issue } from '$lib/server/db/schema';
|
||||||
|
import { desc, eq, sql } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) {
|
||||||
|
throw redirect(302, '/auth/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select({
|
||||||
|
id: project.id,
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
status: project.status,
|
||||||
|
taskCount: sql<number>`(
|
||||||
|
select count(*) from ${task} where ${task.projectId} = ${project.id}
|
||||||
|
)`.mapWith(Number),
|
||||||
|
doneCount: sql<number>`(
|
||||||
|
select count(*) from ${task}
|
||||||
|
where ${task.projectId} = ${project.id} and ${task.status} = 'done'
|
||||||
|
)`.mapWith(Number),
|
||||||
|
issueCount: sql<number>`(
|
||||||
|
select count(*) from ${issue} where ${issue.projectId} = ${project.id}
|
||||||
|
)`.mapWith(Number)
|
||||||
|
})
|
||||||
|
.from(project)
|
||||||
|
.where(eq(project.ownerId, event.locals.user.id))
|
||||||
|
.orderBy(desc(project.updatedAt));
|
||||||
|
|
||||||
|
const normalizedProjects = projects.map((item) => ({
|
||||||
|
...item,
|
||||||
|
progress: item.taskCount ? Math.round((item.doneCount / item.taskCount) * 100) : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const recentTasks = await db
|
||||||
|
.select({
|
||||||
|
id: task.id,
|
||||||
|
title: task.title,
|
||||||
|
status: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
projectId: task.projectId
|
||||||
|
})
|
||||||
|
.from(task)
|
||||||
|
.innerJoin(project, eq(task.projectId, project.id))
|
||||||
|
.where(eq(project.ownerId, event.locals.user.id))
|
||||||
|
.orderBy(desc(task.updatedAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
const recentIssues = await db
|
||||||
|
.select({
|
||||||
|
id: issue.id,
|
||||||
|
title: issue.title,
|
||||||
|
state: issue.state,
|
||||||
|
provider: issue.provider,
|
||||||
|
projectId: issue.projectId
|
||||||
|
})
|
||||||
|
.from(issue)
|
||||||
|
.innerJoin(project, eq(issue.projectId, project.id))
|
||||||
|
.where(eq(project.ownerId, event.locals.user.id))
|
||||||
|
.orderBy(desc(issue.updatedAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: event.locals.user,
|
||||||
|
projects: normalizedProjects,
|
||||||
|
recentTasks,
|
||||||
|
recentIssues
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,2 +1,104 @@
|
|||||||
<h1>Welcome to SvelteKit</h1>
|
<script lang="ts">
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
import { onMount } from 'svelte';
|
||||||
|
import type { PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data }: { data: PageServerData } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const events = new EventSource('/api/events');
|
||||||
|
events.onmessage = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Taskarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto flex min-h-screen max-w-6xl flex-col gap-8 px-6 py-10">
|
||||||
|
<header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm uppercase tracking-[0.3em] text-slate-500">Taskarr</p>
|
||||||
|
<h1 class="text-4xl font-bold text-slate-900">Projects, tasks, and synced issues in one place</h1>
|
||||||
|
<p class="max-w-2xl text-slate-600">A compact SvelteKit workspace for tracking progress, handling tasks, and keeping GitHub or Gitea issues close to the work.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<a class="rounded-md border border-slate-300 px-4 py-2 text-slate-900" href="/projects">Projects</a>
|
||||||
|
<a class="rounded-md bg-blue-600 px-4 py-2 text-white" href="/integrations">Install GitHub App</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="grid gap-4 md:grid-cols-3">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">Projects</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold">{data.projects.length}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">Recent tasks</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold">{data.recentTasks.length}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">Recent issues</p>
|
||||||
|
<p class="mt-2 text-3xl font-semibold">{data.recentIssues.length}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-blue-200 bg-blue-50 p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold text-slate-900">Connect GitHub</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Install the GitHub App to link repositories and sync issues into your projects.</p>
|
||||||
|
<a class="mt-4 inline-flex rounded-md bg-slate-900 px-4 py-2 text-white" href="/integrations">Go to integrations</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Projects</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each data.projects as project}
|
||||||
|
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">{project.name}</p>
|
||||||
|
<p class="text-sm text-slate-500">{project.description}</p>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-slate-500">{project.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 h-2 rounded-full bg-slate-200">
|
||||||
|
<div class="h-2 rounded-full bg-blue-600" style={`width: ${project.progress}%`}></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">{project.taskCount} tasks · {project.issueCount} issues · {project.status}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-6">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Recent tasks</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each data.recentTasks as task}
|
||||||
|
<div class="flex items-center justify-between rounded-xl bg-slate-50 p-4">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-slate-900">{task.title}</p>
|
||||||
|
<p class="text-sm text-slate-500">Priority {task.priority} · {task.status}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Recent issues</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each data.recentIssues as item}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{item.title}</p>
|
||||||
|
<p class="text-sm text-slate-500">{item.provider} · {item.state}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|||||||
34
src/routes/api/events/+server.ts
Normal file
34
src/routes/api/events/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { subscribe } from '$lib/server/events';
|
||||||
|
|
||||||
|
export const GET = () => {
|
||||||
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let stop: (() => void) | undefined;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
stop = subscribe(controller);
|
||||||
|
heartbeat = setInterval(() => {
|
||||||
|
try {
|
||||||
|
controller.enqueue(': ping\n\n');
|
||||||
|
} catch {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
stop?.();
|
||||||
|
}
|
||||||
|
}, 25000);
|
||||||
|
|
||||||
|
controller.enqueue(': connected\n\n');
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
if (heartbeat) clearInterval(heartbeat);
|
||||||
|
stop?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'text/event-stream',
|
||||||
|
'cache-control': 'no-cache',
|
||||||
|
'connection': 'keep-alive'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
34
src/routes/api/github/install/callback/+server.ts
Normal file
34
src/routes/api/github/install/callback/+server.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { redirect, error } from '@sveltejs/kit';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { githubInstallation } from '$lib/server/db/schema';
|
||||||
|
import { decodeInstallState, getGitHubInstallation } from '$lib/server/github-app';
|
||||||
|
|
||||||
|
export const GET = async ({ url, locals }) => {
|
||||||
|
const installationId = url.searchParams.get('installation_id');
|
||||||
|
const state = decodeInstallState(url.searchParams.get('state') ?? undefined);
|
||||||
|
|
||||||
|
if (!installationId) throw error(400, 'Missing installation_id');
|
||||||
|
if (!state.userId) throw error(400, 'Missing install state');
|
||||||
|
if (!locals.user || locals.user.id !== state.userId) throw redirect(302, '/auth/login');
|
||||||
|
|
||||||
|
const installation = await getGitHubInstallation(installationId);
|
||||||
|
const accountLogin = installation.account?.login ?? 'github';
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(githubInstallation)
|
||||||
|
.values({
|
||||||
|
userId: locals.user.id,
|
||||||
|
installationId,
|
||||||
|
accountLogin
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubInstallation.installationId,
|
||||||
|
set: {
|
||||||
|
userId: locals.user.id,
|
||||||
|
accountLogin,
|
||||||
|
updatedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
throw redirect(303, state.redirect);
|
||||||
|
};
|
||||||
@@ -5,7 +5,7 @@ import { auth } from '$lib/server/auth';
|
|||||||
|
|
||||||
export const load: PageServerLoad = (event) => {
|
export const load: PageServerLoad = (event) => {
|
||||||
if (!event.locals.user) {
|
if (!event.locals.user) {
|
||||||
return redirect(302, '/auth/login');
|
throw redirect(302, '/auth/login');
|
||||||
}
|
}
|
||||||
return { user: event.locals.user };
|
return { user: event.locals.user };
|
||||||
};
|
};
|
||||||
@@ -15,6 +15,6 @@ export const actions: Actions = {
|
|||||||
await auth.api.signOut({
|
await auth.api.signOut({
|
||||||
headers: event.request.headers
|
headers: event.request.headers
|
||||||
});
|
});
|
||||||
return redirect(302, '/auth/login');
|
throw redirect(302, '/auth/login');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { APIError } from 'better-auth/api';
|
|||||||
|
|
||||||
export const load: PageServerLoad = (event) => {
|
export const load: PageServerLoad = (event) => {
|
||||||
if (event.locals.user) {
|
if (event.locals.user) {
|
||||||
return redirect(302, '/demo/auth');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
@@ -32,7 +32,7 @@ export const actions: Actions = {
|
|||||||
return fail(500, { message: 'Unexpected error' });
|
return fail(500, { message: 'Unexpected error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(302, '/demo/auth');
|
throw redirect(302, '/');
|
||||||
},
|
},
|
||||||
signUpEmail: async (event) => {
|
signUpEmail: async (event) => {
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
@@ -56,12 +56,12 @@ export const actions: Actions = {
|
|||||||
return fail(500, { message: 'Unexpected error' });
|
return fail(500, { message: 'Unexpected error' });
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect(302, '/demo/auth');
|
throw redirect(302, '/');
|
||||||
},
|
},
|
||||||
signInSocial: async (event) => {
|
signInSocial: async (event) => {
|
||||||
const formData = await event.request.formData();
|
const formData = await event.request.formData();
|
||||||
const provider = formData.get('provider')?.toString() ?? 'github';
|
const provider = formData.get('provider')?.toString() ?? 'github';
|
||||||
const callbackURL = formData.get('callbackURL')?.toString() ?? '/demo/auth';
|
const callbackURL = formData.get('callbackURL')?.toString() ?? '/';
|
||||||
|
|
||||||
const result = await auth.api.signInSocial({
|
const result = await auth.api.signInSocial({
|
||||||
body: {
|
body: {
|
||||||
@@ -71,7 +71,7 @@ export const actions: Actions = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (result.url) {
|
if (result.url) {
|
||||||
return redirect(302, result.url);
|
throw redirect(302, result.url);
|
||||||
}
|
}
|
||||||
return fail(400, { message: 'Social sign-in failed' });
|
return fail(400, { message: 'Social sign-in failed' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
<form method="post" action="?/signInSocial" use:enhance>
|
<form method="post" action="?/signInSocial" use:enhance>
|
||||||
<input type="hidden" name="provider" value="github" />
|
<input type="hidden" name="provider" value="github" />
|
||||||
<input type="hidden" name="callbackURL" value="/demo/better-auth" />
|
<input type="hidden" name="callbackURL" value="/" />
|
||||||
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
<button class="rounded-md bg-blue-600 px-4 py-2 text-white transition hover:bg-blue-700"
|
||||||
>Sign in with GitHub</button
|
>Sign in with GitHub</button
|
||||||
>
|
>
|
||||||
|
|||||||
53
src/routes/integrations/+page.server.ts
Normal file
53
src/routes/integrations/+page.server.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { githubInstallation, githubRepositoryLink, project } from '$lib/server/db/schema';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { getGitHubInstallUrl, listGitHubInstallationRepositories } from '$lib/server/github-app';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
|
||||||
|
const installations = await db
|
||||||
|
.select()
|
||||||
|
.from(githubInstallation)
|
||||||
|
.where(eq(githubInstallation.userId, event.locals.user.id))
|
||||||
|
.orderBy(desc(githubInstallation.updatedAt));
|
||||||
|
|
||||||
|
const linkedRepos = await db
|
||||||
|
.select({
|
||||||
|
id: githubRepositoryLink.id,
|
||||||
|
fullName: githubRepositoryLink.fullName,
|
||||||
|
owner: githubRepositoryLink.owner,
|
||||||
|
repo: githubRepositoryLink.repo,
|
||||||
|
projectName: project.name
|
||||||
|
})
|
||||||
|
.from(githubRepositoryLink)
|
||||||
|
.innerJoin(project, eq(githubRepositoryLink.projectId, project.id))
|
||||||
|
.where(eq(project.ownerId, event.locals.user.id))
|
||||||
|
.orderBy(desc(githubRepositoryLink.updatedAt));
|
||||||
|
|
||||||
|
const installUrl = getGitHubInstallUrl({ redirect: '/integrations', userId: event.locals.user.id });
|
||||||
|
|
||||||
|
const repositoryCatalog = await Promise.all(
|
||||||
|
installations.map(async (installation) => ({
|
||||||
|
installationId: installation.id,
|
||||||
|
accountLogin: installation.accountLogin,
|
||||||
|
repositories: await listGitHubInstallationRepositories(installation.installationId)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
installUrl,
|
||||||
|
installations,
|
||||||
|
linkedRepos,
|
||||||
|
repositoryCatalog
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
startInstall: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
throw redirect(303, getGitHubInstallUrl({ redirect: '/integrations', userId: event.locals.user.id }));
|
||||||
|
}
|
||||||
|
};
|
||||||
64
src/routes/integrations/+page.svelte
Normal file
64
src/routes/integrations/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData, PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Integrations · Taskarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-slate-900">GitHub App integration</h1>
|
||||||
|
<p class="text-slate-600">Install the app on GitHub, then link repositories to projects.</p>
|
||||||
|
</div>
|
||||||
|
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Install app</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">Install the GitHub App to choose the repositories it can access.</p>
|
||||||
|
<div class="mt-4 flex gap-3">
|
||||||
|
<a class="rounded-md bg-blue-600 px-4 py-2 text-white" href={data.installUrl}>Install GitHub App</a>
|
||||||
|
<form method="post" action="?/startInstall">
|
||||||
|
<button class="rounded-md border border-slate-300 px-4 py-2">Open install flow</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Installed accounts</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#if data.installations.length}
|
||||||
|
{#each data.installations as installation}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{installation.accountLogin}</p>
|
||||||
|
<p class="text-sm text-slate-500">Installation ID {installation.installationId}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-500">No installations yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Linked repositories</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#if data.linkedRepos.length}
|
||||||
|
{#each data.linkedRepos as repo}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{repo.fullName}</p>
|
||||||
|
<p class="text-sm text-slate-500">Linked to {repo.projectName}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-slate-500">No linked repositories yet.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
39
src/routes/projects/+page.server.ts
Normal file
39
src/routes/projects/+page.server.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { project } from '$lib/server/db/schema';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
import { publish } from '$lib/server/events';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
|
||||||
|
const projects = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(eq(project.ownerId, event.locals.user.id))
|
||||||
|
.orderBy(desc(project.updatedAt));
|
||||||
|
|
||||||
|
return { projects };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
create: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const name = formData.get('name')?.toString().trim() ?? '';
|
||||||
|
const description = formData.get('description')?.toString().trim() ?? '';
|
||||||
|
|
||||||
|
if (!name) return { error: 'Project name is required' };
|
||||||
|
|
||||||
|
await db.insert(project).values({
|
||||||
|
ownerId: event.locals.user.id,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
|
||||||
|
publish('project-created');
|
||||||
|
|
||||||
|
throw redirect(303, '/projects');
|
||||||
|
}
|
||||||
|
};
|
||||||
38
src/routes/projects/+page.svelte
Normal file
38
src/routes/projects/+page.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData, PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Projects · Taskarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-slate-900">Projects</h1>
|
||||||
|
<p class="text-slate-600">Create and track the work that matters.</p>
|
||||||
|
</div>
|
||||||
|
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/">Dashboard</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="?/create" class="mt-8 grid gap-3 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<input name="name" placeholder="Project name" class="rounded-md border border-slate-300 px-3 py-2" />
|
||||||
|
<textarea name="description" placeholder="Short description" class="rounded-md border border-slate-300 px-3 py-2"></textarea>
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="text-sm text-red-600">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Create project</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-4 md:grid-cols-2">
|
||||||
|
{#each data.projects as project}
|
||||||
|
<a href={`/projects/${project.id}`} class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-blue-300">
|
||||||
|
<p class="text-lg font-semibold text-slate-900">{project.name}</p>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{project.description}</p>
|
||||||
|
<p class="mt-3 text-xs uppercase tracking-[0.2em] text-slate-400">{project.status}</p>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
148
src/routes/projects/[id]/+page.server.ts
Normal file
148
src/routes/projects/[id]/+page.server.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { githubRepositoryLink, issue, project, task } from '$lib/server/db/schema';
|
||||||
|
import { desc, eq, and, ne } from 'drizzle-orm';
|
||||||
|
import { publish } from '$lib/server/events';
|
||||||
|
import { listGitHubRepositoryIssues } from '$lib/server/github-app';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
if (!Number.isFinite(projectId)) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!record) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const tasks = await db
|
||||||
|
.select()
|
||||||
|
.from(task)
|
||||||
|
.where(eq(task.projectId, projectId))
|
||||||
|
.orderBy(desc(task.updatedAt));
|
||||||
|
|
||||||
|
const completedTasks = tasks.filter((item) => item.status === 'done').length;
|
||||||
|
const progress = tasks.length ? Math.round((completedTasks / tasks.length) * 100) : 0;
|
||||||
|
|
||||||
|
if (record.progress !== progress) {
|
||||||
|
await db.update(project).set({ progress }).where(eq(project.id, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = await db
|
||||||
|
.select()
|
||||||
|
.from(issue)
|
||||||
|
.where(eq(issue.projectId, projectId))
|
||||||
|
.orderBy(desc(issue.updatedAt));
|
||||||
|
|
||||||
|
const repositoryLinks = await db
|
||||||
|
.select()
|
||||||
|
.from(githubRepositoryLink)
|
||||||
|
.where(eq(githubRepositoryLink.projectId, projectId));
|
||||||
|
|
||||||
|
return { project: record, tasks, issues, repositoryLinks };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
createTask: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
if (!Number.isFinite(projectId)) throw error(404, 'Project not found');
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const title = formData.get('title')?.toString().trim() ?? '';
|
||||||
|
const description = formData.get('description')?.toString().trim() ?? '';
|
||||||
|
|
||||||
|
if (!title) return { error: 'Task title is required' };
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!record) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
await db.insert(task).values({
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
description
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.update(project).set({ updatedAt: new Date() }).where(eq(project.id, projectId));
|
||||||
|
publish(`task-created:${projectId}`);
|
||||||
|
|
||||||
|
throw redirect(303, `/projects/${projectId}`);
|
||||||
|
},
|
||||||
|
updateTaskStatus: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
if (!Number.isFinite(projectId)) throw error(404, 'Project not found');
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const taskId = Number(formData.get('taskId'));
|
||||||
|
const status = formData.get('status')?.toString();
|
||||||
|
|
||||||
|
if (!Number.isFinite(taskId) || !status) throw error(400, 'Invalid task update');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(task)
|
||||||
|
.set({ status: status as 'todo' | 'doing' | 'blocked' | 'done' })
|
||||||
|
.where(and(eq(task.id, taskId), eq(task.projectId, projectId)));
|
||||||
|
|
||||||
|
await db.update(project).set({ updatedAt: new Date() }).where(eq(project.id, projectId));
|
||||||
|
publish(`task-updated:${projectId}`);
|
||||||
|
|
||||||
|
throw redirect(303, `/projects/${projectId}`);
|
||||||
|
},
|
||||||
|
syncIssues: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
if (!Number.isFinite(projectId)) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!record) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const [linkedRepository] = await db
|
||||||
|
.select()
|
||||||
|
.from(githubRepositoryLink)
|
||||||
|
.where(eq(githubRepositoryLink.projectId, projectId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!linkedRepository) throw error(400, 'Link a repository first');
|
||||||
|
|
||||||
|
const githubIssues = await listGitHubRepositoryIssues({
|
||||||
|
installationId: String(linkedRepository.installationId),
|
||||||
|
owner: linkedRepository.owner,
|
||||||
|
repo: linkedRepository.repo
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.delete(issue).where(and(eq(issue.projectId, projectId), eq(issue.provider, 'github')));
|
||||||
|
|
||||||
|
for (const item of githubIssues.filter((entry) => !entry.pull_request)) {
|
||||||
|
await db
|
||||||
|
.insert(issue)
|
||||||
|
.values({
|
||||||
|
projectId,
|
||||||
|
provider: 'github',
|
||||||
|
externalId: String(item.id),
|
||||||
|
url: item.html_url,
|
||||||
|
title: item.title,
|
||||||
|
state: item.state,
|
||||||
|
labels: JSON.stringify(item.labels.map((label) => label.name))
|
||||||
|
})
|
||||||
|
;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(`issues-synced:${projectId}`);
|
||||||
|
|
||||||
|
throw redirect(303, `/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
94
src/routes/projects/[id]/+page.svelte
Normal file
94
src/routes/projects/[id]/+page.svelte
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ActionData, PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const events = new EventSource('/api/events');
|
||||||
|
events.onmessage = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.project.name} · Taskarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto min-h-screen max-w-5xl px-6 py-10">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-slate-900">{data.project.name}</h1>
|
||||||
|
<p class="text-slate-600">{data.project.description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a class="rounded-md border border-slate-300 px-4 py-2" href={`/projects/${data.project.id}/link-repository`}>Link repo</a>
|
||||||
|
<a class="rounded-md bg-slate-900 px-4 py-2 text-white" href="/projects">Back</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6 lg:grid-cols-2">
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Tasks</h2>
|
||||||
|
<form method="post" action="?/createTask" class="mt-4 grid gap-3">
|
||||||
|
<input name="title" placeholder="Task title" class="rounded-md border border-slate-300 px-3 py-2" />
|
||||||
|
<textarea name="description" placeholder="Task description" class="rounded-md border border-slate-300 px-3 py-2"></textarea>
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="text-sm text-red-600">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Add task</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-3">
|
||||||
|
{#each data.tasks as item}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{item.title}</p>
|
||||||
|
<p class="text-sm text-slate-500">{item.status} · priority {item.priority}</p>
|
||||||
|
<form method="post" action="?/updateTaskStatus" class="mt-3 flex gap-2">
|
||||||
|
<input type="hidden" name="taskId" value={item.id} />
|
||||||
|
<select name="status" class="rounded-md border border-slate-300 px-2 py-1 text-sm">
|
||||||
|
<option value="todo">Todo</option>
|
||||||
|
<option value="doing">Doing</option>
|
||||||
|
<option value="blocked">Blocked</option>
|
||||||
|
<option value="done">Done</option>
|
||||||
|
</select>
|
||||||
|
<button class="rounded-md bg-slate-900 px-3 py-1 text-sm text-white">Update</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">Issues</h2>
|
||||||
|
<form method="post" action="?/syncIssues">
|
||||||
|
<button class="rounded-md bg-slate-900 px-4 py-2 text-white">Sync GitHub issues</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-3">
|
||||||
|
{#each data.issues as item}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{item.title}</p>
|
||||||
|
<p class="text-sm text-slate-500">{item.provider} · {item.state}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h2 class="text-lg font-semibold">Linked repositories</h2>
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
{#each data.repositoryLinks as repo}
|
||||||
|
<div class="rounded-xl bg-slate-50 p-4">
|
||||||
|
<p class="font-medium text-slate-900">{repo.fullName}</p>
|
||||||
|
<p class="text-sm text-slate-500">{repo.owner}/{repo.repo}</p>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
89
src/routes/projects/[id]/link-repository/+page.server.ts
Normal file
89
src/routes/projects/[id]/link-repository/+page.server.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import { db } from '$lib/server/db';
|
||||||
|
import { githubInstallation, githubRepositoryLink, project } from '$lib/server/db/schema';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
import { listGitHubInstallationRepositories } from '$lib/server/github-app';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
if (!Number.isFinite(projectId)) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const [record] = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!record) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const installations = await db
|
||||||
|
.select()
|
||||||
|
.from(githubInstallation)
|
||||||
|
.where(eq(githubInstallation.userId, event.locals.user.id));
|
||||||
|
|
||||||
|
const repositoryCatalog = await Promise.all(
|
||||||
|
installations.map(async (installation) => ({
|
||||||
|
installationId: installation.id,
|
||||||
|
accountLogin: installation.accountLogin,
|
||||||
|
repositories: await listGitHubInstallationRepositories(installation.installationId)
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return { project: record, installations, repositoryCatalog };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
link: async (event) => {
|
||||||
|
if (!event.locals.user) throw redirect(302, '/auth/login');
|
||||||
|
const projectId = Number(event.params.id);
|
||||||
|
const formData = await event.request.formData();
|
||||||
|
const repository = formData.get('repository')?.toString() ?? '';
|
||||||
|
|
||||||
|
if (!repository) {
|
||||||
|
return { error: 'Choose a repository to link' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(repository) as {
|
||||||
|
installationId: number;
|
||||||
|
owner: string;
|
||||||
|
repo: string;
|
||||||
|
fullName: string;
|
||||||
|
defaultBranch: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!parsed.installationId || !parsed.fullName) {
|
||||||
|
return { error: 'Choose a valid repository' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [projectRecord] = await db
|
||||||
|
.select()
|
||||||
|
.from(project)
|
||||||
|
.where(and(eq(project.id, projectId), eq(project.ownerId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!projectRecord) throw error(404, 'Project not found');
|
||||||
|
|
||||||
|
const [installation] = await db
|
||||||
|
.select()
|
||||||
|
.from(githubInstallation)
|
||||||
|
.where(and(eq(githubInstallation.id, parsed.installationId), eq(githubInstallation.userId, event.locals.user.id)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!installation) throw error(404, 'Installation not found');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(githubRepositoryLink)
|
||||||
|
.values({
|
||||||
|
projectId,
|
||||||
|
installationId: installation.id,
|
||||||
|
owner: parsed.owner,
|
||||||
|
repo: parsed.repo,
|
||||||
|
fullName: parsed.fullName,
|
||||||
|
defaultBranch: parsed.defaultBranch || 'main'
|
||||||
|
});
|
||||||
|
|
||||||
|
throw redirect(303, `/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
44
src/routes/projects/[id]/link-repository/+page.svelte
Normal file
44
src/routes/projects/[id]/link-repository/+page.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ActionData, PageServerData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageServerData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Link repository · Taskarr</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<main class="mx-auto min-h-screen max-w-3xl px-6 py-10">
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<h1 class="text-3xl font-bold text-slate-900">Link a repository</h1>
|
||||||
|
<p class="mt-2 text-slate-600">Attach a GitHub repository to {data.project.name} using an installed app.</p>
|
||||||
|
|
||||||
|
<form method="post" action="?/link" class="mt-6 grid gap-3">
|
||||||
|
<select name="repository" class="rounded-md border border-slate-300 px-3 py-2">
|
||||||
|
<option value="">Choose a repository</option>
|
||||||
|
{#each data.repositoryCatalog as installation}
|
||||||
|
<optgroup label={installation.accountLogin}>
|
||||||
|
{#each installation.repositories as repo}
|
||||||
|
<option
|
||||||
|
value={JSON.stringify({
|
||||||
|
installationId: installation.installationId,
|
||||||
|
owner: repo.owner.login,
|
||||||
|
repo: repo.name,
|
||||||
|
fullName: repo.full_name,
|
||||||
|
defaultBranch: repo.default_branch
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{repo.full_name}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
<p class="text-sm text-slate-500">Repositories shown here come from the installed GitHub App.</p>
|
||||||
|
{#if form?.error}
|
||||||
|
<p class="text-sm text-red-600">{form.error}</p>
|
||||||
|
{/if}
|
||||||
|
<button class="w-fit rounded-md bg-blue-600 px-4 py-2 text-white">Link repository</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
Reference in New Issue
Block a user