Switch to drizzle

This commit is contained in:
Ondřej 2024-03-05 11:21:09 +01:00
parent c9d946ea0e
commit 4ed65a8034
17 changed files with 1645 additions and 184 deletions

11
drizzle.config.ts Normal file
View file

@ -0,0 +1,11 @@
import "dotenv/config";
import type { Config } from "drizzle-kit";
export default {
schema: "./src/schema.ts",
out: "./drizzle",
driver: "pg",
dbCredentials: {
connectionString: process.env.DATABASE_URL!,
},
} satisfies Config;

View file

@ -0,0 +1,39 @@
DO $$ BEGIN
CREATE TYPE "status" AS ENUM('pending', 'success', 'error');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "letters" (
"id" serial PRIMARY KEY NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now(),
"first_name" text NOT NULL,
"last_name" text NOT NULL,
"lang" text NOT NULL,
"email" text NOT NULL,
"phone" text NOT NULL,
"variant" text NOT NULL,
"gender" text,
"branch" text NOT NULL,
"is_client" boolean NOT NULL,
"message" text NOT NULL,
"is_subscribed" boolean NOT NULL,
"confirmation_token" text NOT NULL,
"is_confirmed" boolean DEFAULT false,
CONSTRAINT "letters_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "messages" (
"id" serial PRIMARY KEY NOT NULL,
"letter_id" integer,
"from" text NOT NULL,
"to" text NOT NULL,
"status" "status" NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "messages" ADD CONSTRAINT "messages_letter_id_letters_id_fk" FOREIGN KEY ("letter_id") REFERENCES "letters"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View file

@ -0,0 +1,195 @@
{
"id": "ba6993c6-c9e8-443c-a3cc-abade1e5d34e",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "5",
"dialect": "pg",
"tables": {
"letters": {
"name": "letters",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"first_name": {
"name": "first_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"last_name": {
"name": "last_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"lang": {
"name": "lang",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": true
},
"variant": {
"name": "variant",
"type": "text",
"primaryKey": false,
"notNull": true
},
"gender": {
"name": "gender",
"type": "text",
"primaryKey": false,
"notNull": false
},
"branch": {
"name": "branch",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_client": {
"name": "is_client",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_subscribed": {
"name": "is_subscribed",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"confirmation_token": {
"name": "confirmation_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"is_confirmed": {
"name": "is_confirmed",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"letters_email_unique": {
"name": "letters_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
}
},
"messages": {
"name": "messages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"letter_id": {
"name": "letter_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "text",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "status",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"messages_letter_id_letters_id_fk": {
"name": "messages_letter_id_letters_id_fk",
"tableFrom": "messages",
"tableTo": "letters",
"columnsFrom": [
"letter_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
}
},
"enums": {
"status": {
"name": "status",
"values": {
"pending": "pending",
"success": "success",
"error": "error"
}
}
},
"schemas": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "pg",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1709563345794,
"tag": "0000_icy_vulcan",
"breakpoints": true
}
]
}

1302
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,25 +10,27 @@
"preview": "astro preview", "preview": "astro preview",
"astro": "astro", "astro": "astro",
"test": "vitest", "test": "vitest",
"migrate": "node --loader ts-node/esm ./scripts/migrate.ts", "db:generate": "drizzle-kit generate:pg",
"db:migrate": "node --loader ts-node/esm ./scripts/migrate.ts",
"tn": "node --loader ts-node/esm" "tn": "node --loader ts-node/esm"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.4.1", "@astrojs/check": "^0.4.1",
"@astrojs/node": "^8.2.0", "@astrojs/node": "^8.2.0",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@types/pg": "^8.11.2",
"astro": "^4.3.2", "astro": "^4.3.2",
"better-sqlite3": "^9.4.0",
"change-case": "^5.4.2", "change-case": "^5.4.2",
"drizzle-orm": "^0.29.4",
"html-template-tag": "^4.0.1", "html-template-tag": "^4.0.1",
"kysely": "^0.27.2", "postgres": "^3.4.3",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.1",
"typescript": "^5.3.3", "typescript": "^5.3.3",
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/better-sqlite3": "^7.6.9",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-kit": "^0.20.14",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"vitest": "^1.3.1" "vitest": "^1.3.1"
}, },

View file

@ -2,19 +2,18 @@ import "dotenv/config";
import { db } from "../src/db"; import { db } from "../src/db";
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import { and, eq } from "drizzle-orm";
import * as schema from "../src/schema";
async function exportEmails() { async function exportEmails() {
const file = await fs.open("./emails.csv", "w"); const file = await fs.open("./emails.csv", "w");
const letters = await db const letters = await db
.selectFrom("letters") .select()
.where("confirmed", "=", 1) .from(schema.letters)
.where("subscribed", "=", 1) .where(and(eq(schema.letters.confirmed, true), eq(schema.letters.subscribed, true)))
.groupBy("email") .groupBy(schema.letters.email);
.select(["email", "firstName", "lastName"])
.execute();
for (const letter of letters) { for (const letter of letters) {
console.log(letter);
file.write(`${letter.email},"${letter.firstName} ${letter.lastName}"\n`); file.write(`${letter.email},"${letter.firstName} ${letter.lastName}"\n`);
} }
} }

View file

@ -1,63 +1,7 @@
import "dotenv/config"; import "dotenv/config";
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import { sql } from "kysely"; const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
import { db } from "../src/db"; migrate(drizzle(migrationClient), { migrationsFolder: "drizzle" });
const migrations = [
db.schema
.createTable("letters")
.addColumn("id", "integer", (c) => c.primaryKey())
.addColumn("created", "datetime", (c) => c.defaultTo(sql`CURRENT_TIMESTAMP`).notNull())
.addColumn("updated", "datetime", (c) => c.defaultTo(sql`CURRENT_TIMESTAMP`).notNull())
.addColumn("firstName", "text", (c) => c.notNull())
.addColumn("lastName", "text", (c) => c.notNull())
.addColumn("language", "text", (c) => c.notNull())
.addColumn("email", "text", (c) => c.notNull())
.addColumn("phone", "text")
.addColumn("variant", "text", (c) => c.notNull())
.addColumn("gender", "text")
.addColumn("branch", "text", (c) => c.notNull())
.addColumn("isClient", "boolean", (c) => c.notNull())
.addColumn("message", "text", (c) => c.notNull())
.addColumn("subscribed", "boolean", (c) => c.notNull())
.addColumn("confirmationToken", "text", (c) => c.notNull())
.addColumn("confirmed", "boolean", (c) => c.notNull().defaultTo(false)),
db.schema
.createTable("messages")
.addColumn("id", "integer", (c) => c.primaryKey())
.addColumn("letterId", "integer", (c) => c.notNull())
.addColumn("from", "text", (c) => c.notNull())
.addColumn("to", "text", (c) => c.notNull())
.addColumn("status", "text", (c) => c.notNull()),
];
async function initMigrationsTable() {
await db.schema
.createTable("migrations")
.ifNotExists()
.addColumn("id", "integer", (c) => c.primaryKey())
.addColumn("executedAt", "datetime", (c) => c.defaultTo(sql`CURRENT_TIMESTAMP`).notNull())
.execute();
}
async function runMigrations() {
initMigrationsTable();
for (const [id, migration] of migrations.entries()) {
const migrationRecord = await db
.selectFrom("migrations")
.where("id", "=", id)
.select(["id", "executedAt"])
.executeTakeFirst();
if (migrationRecord) {
console.log(id, "|", "at", migrationRecord.executedAt);
} else {
await migration.execute();
await db.insertInto("migrations").values({ id }).execute();
console.log(id, "|", "now");
}
}
}
runMigrations();

View file

@ -1,9 +1,11 @@
import "dotenv/config"; import "dotenv/config";
import { db } from "../src/db"; import { db } from "../src/db";
import * as schema from "../src/schema";
import { getEntry, getCollection } from "astro:content"; import { getEntry, getCollection } from "astro:content";
import { mailClient } from "../src/mail"; import { mailClient } from "../src/mail";
import { setTimeout } from "node:timers/promises"; import { setTimeout } from "node:timers/promises";
import { and, eq } from "drizzle-orm";
export const prerender = false; export const prerender = false;
@ -26,18 +28,12 @@ export function getSubject(lang: string, variantNr: string) {
} }
async function processLetters() { async function processLetters() {
const letters = await db const letters = await db.select().from(schema.letters).where(eq(schema.letters.confirmed, true));
.selectFrom("letters")
.where("confirmed", "=", 1)
.where("language", "=", "hu")
.where("id", ">", 177)
.select(["id", "firstName", "lastName", "language", "variant", "branch", "email", "message"])
.execute();
for (const letter of letters) { for (const letter of letters) {
const branch = await getEntry("branches", letter.branch as string); const branch = await getEntry("branches", letter.branch as string);
const subject = getSubject( const subject = getSubject(
letter.branch === "vig-holding" ? "en" : letter.language, letter.branch === "vig-holding" ? "en" : letter.lang,
letter.variant letter.variant
); );
console.log("Subject", subject); console.log("Subject", subject);
@ -48,17 +44,15 @@ async function processLetters() {
} }
for (const recipientEmail of branch.data.emails) { for (const recipientEmail of branch.data.emails) {
console.log("sending", letter.id, "to", recipientEmail); const [message] = await db
const message = await db .insert(schema.messages)
.insertInto("messages")
.values({ .values({
letterId: letter.id, letterId: letter.id,
from: letter.email, from: letter.email,
to: recipientEmail, to: recipientEmail,
status: "pending", status: "pending",
}) })
.returning(["id"]) .returning({ id: schema.messages.id });
.executeTakeFirst();
if (!message) { if (!message) {
console.error("Error creating message"); console.error("Error creating message");
@ -67,7 +61,7 @@ async function processLetters() {
const name = `${letter.firstName} ${letter.lastName}`; const name = `${letter.firstName} ${letter.lastName}`;
const messageText = const messageText =
letter.language === "hu" letter.lang === "hu"
? letter.message.replace("Tisztelt Hölgyem,", "Tisztelt Hölgyem/Uram,") ? letter.message.replace("Tisztelt Hölgyem,", "Tisztelt Hölgyem/Uram,")
: letter.message; : letter.message;
@ -83,17 +77,15 @@ async function processLetters() {
console.log(result); console.log(result);
await db await db
.updateTable("messages") .update(schema.messages)
.set({ status: "success" }) .set({ status: "success" })
.where("id", "=", message.id) .where(eq(schema.messages.id, message.id));
.execute();
} catch (error) { } catch (error) {
console.log(error); console.log(error);
await db await db
.updateTable("messages") .update(schema.messages)
.set({ status: "error" }) .set({ status: "error" })
.where("id", "=", message.id) .where(eq(schema.messages.id, message.id));
.execute();
} }
await setTimeout(2000); await setTimeout(2000);

6
src/db.ts Normal file
View file

@ -0,0 +1,6 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(import.meta.env.DATABASE_URL);
export const db = drizzle(client, { schema });

View file

@ -1,16 +0,0 @@
import { Kysely, SqliteDialect } from "kysely";
import Database from "better-sqlite3";
const path = process.env.DATABASE_PATH;
const database = new Database(path);
database.exec("PRAGMA journal_mode = WAL;");
const dialect = new SqliteDialect({
database,
});
const kysely = new Kysely<any>({
dialect,
});
export { kysely as db };

View file

@ -1,25 +0,0 @@
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
? ColumnType<S, I | undefined, U>
: ColumnType<T, T | undefined, T>;
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
export type Letter = {
id: Generated<number>;
created: Generated<string>;
updated: Generated<string>;
firstName: string;
lastName: string;
email: string;
phone: string | null;
variant: string;
gender: string | null;
branch: string;
message: string;
subscribed: number;
confirmationToken: string;
confirmed: Generated<number>;
};
export type DB = {
Letter: Letter;
};

View file

@ -1,3 +1,4 @@
import "dotenv/config";
import type { MiddlewareHandler } from "astro"; import type { MiddlewareHandler } from "astro";
import { isKnownLang } from "./lang"; import { isKnownLang } from "./lang";

View file

@ -1,6 +1,8 @@
import type { APIContext } from "astro"; import type { APIContext } from "astro";
import { db } from "../../../db"; import { db } from "../../../db";
import * as schema from "../../../schema";
import type { Lang } from "../../../lang"; import type { Lang } from "../../../lang";
import { eq } from "drizzle-orm";
export const prerender = false; export const prerender = false;
@ -10,18 +12,19 @@ interface Params extends Record<string, string> {
} }
export async function GET({ params: { lang, token }, redirect }: APIContext<never, Params>) { export async function GET({ params: { lang, token }, redirect }: APIContext<never, Params>) {
const letter = await db const letter = await db.query.letters.findFirst({
.selectFrom("letters") where: eq(schema.letters.confirmationToken, token),
.where("confirmationToken", "=", token) });
.select(["id", "confirmationToken", "confirmed"])
.executeTakeFirst();
if (!letter) { if (!letter) {
return new Response(null, { status: 404 }); return new Response(null, { status: 404 });
} }
if (!letter.confirmed) { if (!letter.confirmed) {
db.updateTable("letters").set({ confirmed: 1 }).where("id", "=", letter.id).execute(); await db
.update(schema.letters)
.set({ confirmed: true })
.where(eq(schema.letters.id, letter.id));
} }
return redirect(`/${lang}/email-confirmed`, 301); return redirect(`/${lang}/email-confirmed`, 301);

View file

@ -2,10 +2,12 @@ import type { APIContext } from "astro";
import type { Lang } from "../../lang"; import type { Lang } from "../../lang";
import { letterFormSchema } from "../../letter/schema"; import { letterFormSchema } from "../../letter/schema";
import { db } from "../../db"; import { db } from "../../db";
import * as schema from "../../schema";
import { generateToken } from "../../utils"; import { generateToken } from "../../utils";
import { mailClient } from "../../mail"; import { mailClient } from "../../mail";
import { renderMail } from "../../mails/confirm-email"; import { renderMail } from "../../mails/confirm-email";
import { makeT } from "../../i18n"; import { makeT } from "../../i18n";
import { and, eq } from "drizzle-orm";
export const prerender = false; export const prerender = false;
@ -28,12 +30,9 @@ export async function POST({ request, params: { lang }, redirect }: APIContext<n
const { data } = validationResult; const { data } = validationResult;
const existingLetter = await db const existingLetter = await db.query.letters.findFirst({
.selectFrom("letters") where: and(eq(schema.letters.email, data.email), eq(schema.letters.branch, data.branch)),
.where("email", "=", data.email) });
.where("branch", "=", data.branch)
.select("id")
.executeTakeFirst();
if (existingLetter) { if (existingLetter) {
return redirect(`/${lang}/letter-exists`, 303); return redirect(`/${lang}/letter-exists`, 303);
@ -41,19 +40,17 @@ export async function POST({ request, params: { lang }, redirect }: APIContext<n
const values = { const values = {
...data, ...data,
language: lang,
confirmationToken: generateToken(32), confirmationToken: generateToken(32),
updated: new Date().toISOString(), updated: new Date(),
isClient: Number(data.subscribed), phone: data.phone ?? "",
subscribed: Number(data.subscribed), lang,
confirmed: 0, } satisfies typeof schema.letters.$inferInsert;
};
const letterRecord = await db const [letterRecord] = await db.insert(schema.letters).values(values).returning({
.insertInto("letters") id: schema.letters.id,
.values(values) email: schema.letters.email,
.returning(["id", "email", "confirmationToken"]) confirmationToken: schema.letters.confirmationToken,
.executeTakeFirst(); });
if (!letterRecord) { if (!letterRecord) {
throw new Error("No letter record returned from database."); throw new Error("No letter record returned from database.");

View file

@ -1,24 +1,24 @@
--- ---
import { count, countDistinct, desc, eq } from "drizzle-orm";
import { db } from "../db"; import { db } from "../db";
import * as schema from "../schema";
export const prerender = false; export const prerender = false;
const query = db.selectFrom("letters").select(({ fn }) => [fn.count("id").as("count")]); const query = db.select({ count: count() }).from(schema.letters);
const [totalSubmissions] = await query.execute(); const [totalSubmissions] = await query;
const [confirmedSubmissions] = await query.where("confirmed", "=", 1).execute(); const [confirmedSubmissions] = await query.where(eq(schema.letters.confirmed, true)).execute();
const submissionsByLanguage = await db const submissionsByLanguage = await db
.selectFrom("letters") .select({ count: count(), lang: schema.letters.lang })
.groupBy("language") .from(schema.letters)
.select(({ fn }) => ["language", fn.count("id").as("count")]) .groupBy(schema.letters.lang)
.orderBy("count", "desc") .orderBy((row) => desc(row.count));
.execute();
const [uniqueSenders] = await db const [uniqueSenders] = await db
.selectFrom("letters") .select({ count: countDistinct(schema.letters.email) })
.select(({ fn }) => [fn.count("email").distinct().as("count")]) .from(schema.letters);
.execute();
--- ---
<div> <div>
@ -37,7 +37,7 @@ const [uniqueSenders] = await db
{ {
submissionsByLanguage.map((row) => ( submissionsByLanguage.map((row) => (
<tr> <tr>
<th class="text-left">{row.language}</th> <th class="text-left">{row.lang}</th>
<th class="text-right">{row.count}</th> <th class="text-right">{row.count}</th>
</tr> </tr>
)) ))

30
src/schema.ts Normal file
View file

@ -0,0 +1,30 @@
import { text, boolean, pgTable, serial, timestamp, integer, pgEnum } from "drizzle-orm/pg-core";
export const letters = pgTable("letters", {
id: serial("id").primaryKey(),
createdAt: timestamp("created_at").defaultNow(),
updated: timestamp("updated_at").defaultNow(),
firstName: text("first_name").notNull(),
lastName: text("last_name").notNull(),
lang: text("lang").notNull(),
email: text("email").notNull().unique(),
phone: text("phone").notNull(),
variant: text("variant").notNull(),
gender: text("gender"),
branch: text("branch").notNull(),
isClient: boolean("is_client").notNull(),
message: text("message").notNull(),
subscribed: boolean("is_subscribed").notNull(),
confirmationToken: text("confirmation_token").notNull(),
confirmed: boolean("is_confirmed").default(false),
});
export const statusEnum = pgEnum("status", ["pending", "success", "error"]);
export const messages = pgTable("messages", {
id: serial("id").primaryKey(),
letterId: integer("letter_id").references(() => letters.id),
from: text("from").notNull(),
to: text("to").notNull(),
status: statusEnum("status").notNull(),
});