From 0346016789c2bf2ade7f039062b3c343e52904e2 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:52 +0200 Subject: [PATCH 1/7] feat(api): add profile fields, follows table, per-writer subscriber unique Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/migrations/0003_amazing_skreet.sql | 22 + apps/api/migrations/meta/0003_snapshot.json | 3119 +++++++++++++++++++ apps/api/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 72 +- 4 files changed, 3203 insertions(+), 17 deletions(-) create mode 100644 apps/api/migrations/0003_amazing_skreet.sql create mode 100644 apps/api/migrations/meta/0003_snapshot.json diff --git a/apps/api/migrations/0003_amazing_skreet.sql b/apps/api/migrations/0003_amazing_skreet.sql new file mode 100644 index 0000000..b9d21d7 --- /dev/null +++ b/apps/api/migrations/0003_amazing_skreet.sql @@ -0,0 +1,22 @@ +CREATE TABLE `follows` ( + `id` text PRIMARY KEY NOT NULL, + `follower_id` text NOT NULL, + `following_id` text NOT NULL, + `created_at` integer, + FOREIGN KEY (`follower_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`following_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `follows_following_id_idx` ON `follows` (`following_id`);--> statement-breakpoint +CREATE INDEX `follows_follower_id_idx` ON `follows` (`follower_id`);--> statement-breakpoint +CREATE UNIQUE INDEX `follows_unique` ON `follows` (`follower_id`,`following_id`);--> statement-breakpoint +DROP INDEX `subscribers_email_unique`;--> statement-breakpoint +DROP INDEX `sub_constraint`;--> statement-breakpoint +CREATE UNIQUE INDEX `sub_writer_email` ON `subscribers` (`writer_id`,`email`);--> statement-breakpoint +ALTER TABLE `user` ADD `username` text;--> statement-breakpoint +ALTER TABLE `user` ADD `bio` text;--> statement-breakpoint +ALTER TABLE `user` ADD `website` text;--> statement-breakpoint +ALTER TABLE `user` ADD `location` text;--> statement-breakpoint +ALTER TABLE `user` ADD `social_links` text;--> statement-breakpoint +CREATE UNIQUE INDEX `user_username_unique` ON `user` (`username`);--> statement-breakpoint +CREATE INDEX `user_username_idx` ON `user` (`username`); \ No newline at end of file diff --git a/apps/api/migrations/meta/0003_snapshot.json b/apps/api/migrations/meta/0003_snapshot.json new file mode 100644 index 0000000..0a7d9ca --- /dev/null +++ b/apps/api/migrations/meta/0003_snapshot.json @@ -0,0 +1,3119 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1c0c4fac-7850-4f8d-b9e0-e453bf5b491a", + "prevId": "7d66c1eb-1a4f-474f-96bc-ffce768aaee6", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "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": {}, + "checkConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key_encrypted": { + "name": "key_encrypted", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_idx": { + "name": "api_keys_key_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + }, + "api_keys_user_id_idx": { + "name": "api_keys_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "api_keys_api_user_fkey": { + "name": "api_keys_api_user_fkey", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "campaign_links": { + "name": "campaign_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "camp_id_idx": { + "name": "camp_id_idx", + "columns": [ + "campaign_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "campaign_links_campaign_id_campaigns_id_fk": { + "name": "campaign_links_campaign_id_campaigns_id_fk", + "tableFrom": "campaign_links", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "campaigns": { + "name": "campaigns", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'DRAFT'" + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_sent": { + "name": "total_sent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "total_opens": { + "name": "total_opens", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "total_clicks": { + "name": "total_clicks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_ab_test": { + "name": "is_ab_test", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "parent_campaign_id": { + "name": "parent_campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variant_name": { + "name": "variant_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "variant_percentage": { + "name": "variant_percentage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "campaigns_user_id_idx": { + "name": "campaigns_user_id_idx", + "columns": [ + "userId" + ], + "isUnique": false + }, + "campaigns_status_idx": { + "name": "campaigns_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "campaigns_scheduled_at_idx": { + "name": "campaigns_scheduled_at_idx", + "columns": [ + "scheduled_at" + ], + "isUnique": false + }, + "campaigns_parent_id_idx": { + "name": "campaigns_parent_id_idx", + "columns": [ + "parent_campaign_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "campaigns_userId_user_id_fk": { + "name": "campaigns_userId_user_id_fk", + "tableFrom": "campaigns", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "campaigns_document_id_documents_id_fk": { + "name": "campaigns_document_id_documents_id_fk", + "tableFrom": "campaigns", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "categories": { + "name": "categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#6366f1'" + }, + "writer_id": { + "name": "writer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "categories_writer_id_idx": { + "name": "categories_writer_id_idx", + "columns": [ + "writer_id" + ], + "isUnique": false + }, + "categories_slug_idx": { + "name": "categories_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "categories_writer_slug_unique": { + "name": "categories_writer_slug_unique", + "columns": [ + "writer_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "categories_writer_id_user_id_fk": { + "name": "categories_writer_id_user_id_fk", + "tableFrom": "categories", + "tableTo": "user", + "columnsFrom": [ + "writer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "click_events": { + "name": "click_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subscriber_id": { + "name": "subscriber_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "link_id": { + "name": "link_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "clicked_at": { + "name": "clicked_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sub_id_idx": { + "name": "sub_id_idx", + "columns": [ + "subscriber_id" + ], + "isUnique": false + }, + "camp_ev_id_idx": { + "name": "camp_ev_id_idx", + "columns": [ + "link_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "click_events_subscriber_id_subscribers_id_fk": { + "name": "click_events_subscriber_id_subscribers_id_fk", + "tableFrom": "click_events", + "tableTo": "subscribers", + "columnsFrom": [ + "subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "click_events_link_id_campaign_links_id_fk": { + "name": "click_events_link_id_campaign_links_id_fk", + "tableFrom": "click_events", + "tableTo": "campaign_links", + "columnsFrom": [ + "link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "comments_document_id_idx": { + "name": "comments_document_id_idx", + "columns": [ + "document_id" + ], + "isUnique": false + }, + "comments_user_id_idx": { + "name": "comments_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "comments_parent_id_idx": { + "name": "comments_parent_id_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + }, + "comments_created_at_idx": { + "name": "comments_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_document_id_documents_id_fk": { + "name": "comments_document_id_documents_id_fk", + "tableFrom": "comments", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_user_id_fk": { + "name": "comments_user_id_user_id_fk", + "tableFrom": "comments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_parent_id_fkey": { + "name": "comments_parent_id_fkey", + "tableFrom": "comments", + "tableTo": "comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_categories": { + "name": "document_categories", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category_id": { + "name": "category_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "doc_categories_doc_id_idx": { + "name": "doc_categories_doc_id_idx", + "columns": [ + "document_id" + ], + "isUnique": false + }, + "doc_categories_cat_id_idx": { + "name": "doc_categories_cat_id_idx", + "columns": [ + "category_id" + ], + "isUnique": false + }, + "doc_categories_unique": { + "name": "doc_categories_unique", + "columns": [ + "document_id", + "category_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "document_categories_document_id_documents_id_fk": { + "name": "document_categories_document_id_documents_id_fk", + "tableFrom": "document_categories", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_categories_category_id_categories_id_fk": { + "name": "document_categories_category_id_categories_id_fk", + "tableFrom": "document_categories", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_likes": { + "name": "document_likes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "document_likes_document_id_idx": { + "name": "document_likes_document_id_idx", + "columns": [ + "document_id" + ], + "isUnique": false + }, + "document_likes_user_id_idx": { + "name": "document_likes_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "document_likes_unique": { + "name": "document_likes_unique", + "columns": [ + "document_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "document_likes_document_id_documents_id_fk": { + "name": "document_likes_document_id_documents_id_fk", + "tableFrom": "document_likes", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_likes_user_id_user_id_fk": { + "name": "document_likes_user_id_user_id_fk", + "tableFrom": "document_likes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_tags": { + "name": "document_tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "doc_tags_doc_id_idx": { + "name": "doc_tags_doc_id_idx", + "columns": [ + "document_id" + ], + "isUnique": false + }, + "doc_tags_tag_id_idx": { + "name": "doc_tags_tag_id_idx", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "doc_tags_unique": { + "name": "doc_tags_unique", + "columns": [ + "document_id", + "tag_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "document_tags_document_id_documents_id_fk": { + "name": "document_tags_document_id_documents_id_fk", + "tableFrom": "document_tags", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_tags_tag_id_tags_id_fk": { + "name": "document_tags_tag_id_tags_id_fk", + "tableFrom": "document_tags", + "tableTo": "tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "markdown": { + "name": "markdown", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "banner_image": { + "name": "banner_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_date": { + "name": "published_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_title": { + "name": "meta_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_description": { + "name": "meta_description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "meta_keywords": { + "name": "meta_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "canonical_url": { + "name": "canonical_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "og_image": { + "name": "og_image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reading_time": { + "name": "reading_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "word_count": { + "name": "word_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_featured": { + "name": "is_featured", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "documents_user_id_idx": { + "name": "documents_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "documents_status_idx": { + "name": "documents_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "documents_created_at_idx": { + "name": "documents_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + }, + "documents_scheduled_date_idx": { + "name": "documents_scheduled_date_idx", + "columns": [ + "scheduled_date" + ], + "isUnique": false + }, + "documents_user_status_idx": { + "name": "documents_user_status_idx", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "documents_featured_idx": { + "name": "documents_featured_idx", + "columns": [ + "is_featured" + ], + "isUnique": false + }, + "documents_slug_unique": { + "name": "documents_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "follows": { + "name": "follows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "follows_following_id_idx": { + "name": "follows_following_id_idx", + "columns": [ + "following_id" + ], + "isUnique": false + }, + "follows_follower_id_idx": { + "name": "follows_follower_id_idx", + "columns": [ + "follower_id" + ], + "isUnique": false + }, + "follows_unique": { + "name": "follows_unique", + "columns": [ + "follower_id", + "following_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "follows_follower_id_user_id_fk": { + "name": "follows_follower_id_user_id_fk", + "tableFrom": "follows", + "tableTo": "user", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "follows_following_id_user_id_fk": { + "name": "follows_following_id_user_id_fk", + "tableFrom": "follows", + "tableTo": "user", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "newsletter_deliveries": { + "name": "newsletter_deliveries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subscriber_id": { + "name": "subscriber_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "newsletter_deliveries_campaign_idx": { + "name": "newsletter_deliveries_campaign_idx", + "columns": [ + "campaign_id" + ], + "isUnique": false + }, + "newsletter_deliveries_subscriber_idx": { + "name": "newsletter_deliveries_subscriber_idx", + "columns": [ + "subscriber_id" + ], + "isUnique": false + }, + "newsletter_deliveries_unique": { + "name": "newsletter_deliveries_unique", + "columns": [ + "campaign_id", + "subscriber_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "newsletter_deliveries_subscriber_id_subscribers_id_fk": { + "name": "newsletter_deliveries_subscriber_id_subscribers_id_fk", + "tableFrom": "newsletter_deliveries", + "tableTo": "subscribers", + "columnsFrom": [ + "subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "newsletter_settings": { + "name": "newsletter_settings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "writer_id": { + "name": "writer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "newsletter_name": { + "name": "newsletter_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmation_url": { + "name": "confirmation_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "brand_color": { + "name": "brand_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'#000000'" + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "newsletter_settings_writer_id_unique": { + "name": "newsletter_settings_writer_id_unique", + "columns": [ + "writer_id" + ], + "isUnique": true + }, + "writer_newsletter_writer_id_idx": { + "name": "writer_newsletter_writer_id_idx", + "columns": [ + "writer_id" + ], + "isUnique": false + }, + "writer_newsletter_active_idx": { + "name": "writer_newsletter_active_idx", + "columns": [ + "is_active" + ], + "isUnique": false + } + }, + "foreignKeys": { + "newsletter_settings_writer_id_user_id_fk": { + "name": "newsletter_settings_writer_id_user_id_fk", + "tableFrom": "newsletter_settings", + "tableTo": "user", + "columnsFrom": [ + "writer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_access_tokens": { + "name": "oauth_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked": { + "name": "revoked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_access_tokens_token_unique": { + "name": "oauth_access_tokens_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "oauth_access_tokens_refresh_token_unique": { + "name": "oauth_access_tokens_refresh_token_unique", + "columns": [ + "refresh_token" + ], + "isUnique": true + }, + "oauth_access_tokens_token_idx": { + "name": "oauth_access_tokens_token_idx", + "columns": [ + "token" + ], + "isUnique": false + }, + "oauth_access_tokens_refresh_token_idx": { + "name": "oauth_access_tokens_refresh_token_idx", + "columns": [ + "refresh_token" + ], + "isUnique": false + }, + "oauth_access_tokens_application_id_idx": { + "name": "oauth_access_tokens_application_id_idx", + "columns": [ + "application_id" + ], + "isUnique": false + }, + "oauth_access_tokens_user_id_idx": { + "name": "oauth_access_tokens_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_access_tokens_application_id_fkey": { + "name": "oauth_access_tokens_application_id_fkey", + "tableFrom": "oauth_access_tokens", + "tableTo": "oauth_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_tokens_user_id_fkey": { + "name": "oauth_access_tokens_user_id_fkey", + "tableFrom": "oauth_access_tokens", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_applications": { + "name": "oauth_applications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "install_url": { + "name": "install_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "screenshots": { + "name": "screenshots", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'draft'" + } + }, + "indexes": { + "oauth_applications_slug_unique": { + "name": "oauth_applications_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "oauth_applications_client_id_idx": { + "name": "oauth_applications_client_id_idx", + "columns": [ + "client_id" + ], + "isUnique": false + }, + "oauth_applications_slug_idx": { + "name": "oauth_applications_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_applications_created_by_user_id_fk": { + "name": "oauth_applications_created_by_user_id_fk", + "tableFrom": "oauth_applications", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_authorization_codes": { + "name": "oauth_authorization_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "application_id": { + "name": "application_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_uri": { + "name": "redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used": { + "name": "used", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "code_challenge": { + "name": "code_challenge", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "code_challenge_method": { + "name": "code_challenge_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "oauth_authorization_codes_code_unique": { + "name": "oauth_authorization_codes_code_unique", + "columns": [ + "code" + ], + "isUnique": true + }, + "oauth_authorization_codes_code_idx": { + "name": "oauth_authorization_codes_code_idx", + "columns": [ + "code" + ], + "isUnique": false + }, + "oauth_authorization_codes_application_id_idx": { + "name": "oauth_authorization_codes_application_id_idx", + "columns": [ + "application_id" + ], + "isUnique": false + }, + "oauth_authorization_codes_user_id_idx": { + "name": "oauth_authorization_codes_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "oauth_authorization_codes_application_id_fkey": { + "name": "oauth_authorization_codes_application_id_fkey", + "tableFrom": "oauth_authorization_codes", + "tableTo": "oauth_applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_authorization_codes_user_id_fkey": { + "name": "oauth_authorization_codes_user_id_fkey", + "tableFrom": "oauth_authorization_codes", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "open_events": { + "name": "open_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subscriber_id": { + "name": "subscriber_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "opened_at": { + "name": "opened_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "open_sub_id_idx": { + "name": "open_sub_id_idx", + "columns": [ + "subscriber_id" + ], + "isUnique": false + }, + "open_camp_id_idx": { + "name": "open_camp_id_idx", + "columns": [ + "campaign_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "open_events_subscriber_id_subscribers_id_fk": { + "name": "open_events_subscriber_id_subscribers_id_fk", + "tableFrom": "open_events", + "tableTo": "subscribers", + "columnsFrom": [ + "subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "open_events_campaign_id_campaigns_id_fk": { + "name": "open_events_campaign_id_campaigns_id_fk", + "tableFrom": "open_events", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + } + }, + "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": {}, + "checkConstraints": {} + }, + "subscribers": { + "name": "subscribers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "writer_id": { + "name": "writer_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscribed_at": { + "name": "subscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_confirmed": { + "name": "is_confirmed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unsubscribed": { + "name": "is_unsubscribed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "subscribers_token_unique": { + "name": "subscribers_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "email_idx": { + "name": "email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "token_idx": { + "name": "token_idx", + "columns": [ + "token" + ], + "isUnique": false + }, + "confirmed_idx": { + "name": "confirmed_idx", + "columns": [ + "is_confirmed" + ], + "isUnique": false + }, + "writer_id_idx": { + "name": "writer_id_idx", + "columns": [ + "writer_id" + ], + "isUnique": false + }, + "sub_writer_email": { + "name": "sub_writer_email", + "columns": [ + "writer_id", + "email" + ], + "isUnique": true + } + }, + "foreignKeys": { + "subscribers_writer_id_user_id_fk": { + "name": "subscribers_writer_id_user_id_fk", + "tableFrom": "subscribers", + "tableTo": "user", + "columnsFrom": [ + "writer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "writer_id": { + "name": "writer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "usage_count": { + "name": "usage_count", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "tags_writer_id_idx": { + "name": "tags_writer_id_idx", + "columns": [ + "writer_id" + ], + "isUnique": false + }, + "tags_slug_idx": { + "name": "tags_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tags_writer_slug_unique": { + "name": "tags_writer_slug_unique", + "columns": [ + "writer_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "tags_writer_id_user_id_fk": { + "name": "tags_writer_id_user_id_fk", + "tableFrom": "tags", + "tableTo": "user", + "columnsFrom": [ + "writer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "unsubscribe_events": { + "name": "unsubscribe_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "subscriber_id": { + "name": "subscriber_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "campaign_id": { + "name": "campaign_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "sub_rel_id_idx": { + "name": "sub_rel_id_idx", + "columns": [ + "subscriber_id" + ], + "isUnique": false + }, + "camp_rel_id_idx": { + "name": "camp_rel_id_idx", + "columns": [ + "campaign_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "unsubscribe_events_subscriber_id_subscribers_id_fk": { + "name": "unsubscribe_events_subscriber_id_subscribers_id_fk", + "tableFrom": "unsubscribe_events", + "tableTo": "subscribers", + "columnsFrom": [ + "subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "unsubscribe_events_campaign_id_campaigns_id_fk": { + "name": "unsubscribe_events_campaign_id_campaigns_id_fk", + "tableFrom": "unsubscribe_events", + "tableTo": "campaigns", + "columnsFrom": [ + "campaign_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "social_links": { + "name": "social_links", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_username_unique": { + "name": "user_username_unique", + "columns": [ + "username" + ], + "isUnique": true + }, + "user_username_idx": { + "name": "user_username_idx", + "columns": [ + "username" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_invites": { + "name": "workspace_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'VIEWER'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_invites_token_unique": { + "name": "workspace_invites_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "workspace_invites_workspace_id_idx": { + "name": "workspace_invites_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "workspace_invites_email_idx": { + "name": "workspace_invites_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "workspace_invites_token_idx": { + "name": "workspace_invites_token_idx", + "columns": [ + "token" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_invites_workspace_id_workspaces_id_fk": { + "name": "workspace_invites_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invites_invited_by_user_id_fk": { + "name": "workspace_invites_invited_by_user_id_fk", + "tableFrom": "workspace_invites", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_members": { + "name": "workspace_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'VIEWER'" + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "invited_at": { + "name": "invited_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "joined_at": { + "name": "joined_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "workspace_members_workspace_id_idx": { + "name": "workspace_members_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "workspace_members_user_id_idx": { + "name": "workspace_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "workspace_members_unique": { + "name": "workspace_members_unique", + "columns": [ + "workspace_id", + "user_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "workspace_members_workspace_id_workspaces_id_fk": { + "name": "workspace_members_workspace_id_workspaces_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_user_id_user_id_fk": { + "name": "workspace_members_user_id_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_members_invited_by_user_id_fk": { + "name": "workspace_members_invited_by_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings": { + "name": "settings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_slug_unique": { + "name": "workspaces_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspaces_owner_id_idx": { + "name": "workspaces_owner_id_idx", + "columns": [ + "owner_id" + ], + "isUnique": false + }, + "workspaces_slug_idx": { + "name": "workspaces_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "workspaces_domain_idx": { + "name": "workspaces_domain_idx", + "columns": [ + "domain" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_owner_id_user_id_fk": { + "name": "workspaces_owner_id_user_id_fk", + "tableFrom": "workspaces", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/apps/api/migrations/meta/_journal.json b/apps/api/migrations/meta/_journal.json index fab30dd..dad719d 100644 --- a/apps/api/migrations/meta/_journal.json +++ b/apps/api/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1781642333395, "tag": "0002_documents_fts", "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1781717270983, + "tag": "0003_amazing_skreet", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index d947b8d..18d47ff 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -14,21 +14,35 @@ export const createTable = sqliteTableCreator((name) => name) // AUTHENTICATION & USER MANAGEMENT // ============================================================================ -export const user = sqliteTable('user', { - id: text('id').primaryKey(), - name: text('name').notNull(), - email: text('email').notNull().unique(), - emailVerified: integer('email_verified', { mode: 'boolean' }) - .$defaultFn(() => false) - .notNull(), - image: text('image'), - createdAt: integer('created_at', { mode: 'timestamp' }) - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), - updatedAt: integer('updated_at', { mode: 'timestamp' }) - .$defaultFn(() => /* @__PURE__ */ new Date()) - .notNull(), -}) +export const user = sqliteTable( + 'user', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + email: text('email').notNull().unique(), + emailVerified: integer('email_verified', { mode: 'boolean' }) + .$defaultFn(() => false) + .notNull(), + image: text('image'), + username: text('username').unique(), + bio: text('bio'), + website: text('website'), + location: text('location'), + socialLinks: text('social_links', { mode: 'json' }).$type<{ + twitter?: string + github?: string + linkedin?: string + website?: string + }>(), + createdAt: integer('created_at', { mode: 'timestamp' }) + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + updatedAt: integer('updated_at', { mode: 'timestamp' }) + .$defaultFn(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index('user_username_idx').on(table.username)] +) export const session = sqliteTable('session', { id: text('id').primaryKey(), @@ -274,6 +288,29 @@ export const documentLikes = createTable( export type DocumentLike = typeof documentLikes.$inferSelect export type DocumentLikeInsert = typeof documentLikes.$inferInsert +// Social graph: user -> user follows (unique per follower + following) +export const follows = createTable( + 'follows', + { + id: text('id').primaryKey(), + followerId: text('follower_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + followingId: text('following_id') + .notNull() + .references(() => user.id, { onDelete: 'cascade' }), + createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()), + }, + (table) => [ + index('follows_following_id_idx').on(table.followingId), + index('follows_follower_id_idx').on(table.followerId), + unique('follows_unique').on(table.followerId, table.followingId), + ] +) + +export type Follow = typeof follows.$inferSelect +export type FollowInsert = typeof follows.$inferInsert + // ============================================================================ // NEWSLETTER & EMAIL // ============================================================================ @@ -282,7 +319,7 @@ export const subscribers = createTable( 'subscribers', { id: text().primaryKey(), - email: text().notNull().unique(), + email: text().notNull(), token: text().notNull().unique(), writerId: text('writer_id').references(() => user.id, { onDelete: 'cascade', @@ -298,7 +335,8 @@ export const subscribers = createTable( index('token_idx').on(table.token), index('confirmed_idx').on(table.isConfirmed), index('writer_id_idx').on(table.writerId), - unique('sub_constraint').on(table.email, table.token), + // One subscriber email per writer (supports subscribing to many writers). + unique('sub_writer_email').on(table.writerId, table.email), ] ) From f9a7fdf546459e00fd17d244e4d68cea78eac639 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:52 +0200 Subject: [PATCH 2/7] feat(api): add profile/follows queries and post like counts Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/db/queries/documents.ts | 19 ++- apps/api/src/db/queries/follows.ts | 144 ++++++++++++++++++++ apps/api/src/db/queries/index.ts | 1 + apps/api/src/db/queries/subscribers.ts | 18 +++ apps/api/src/db/queries/users.ts | 130 +++++++++++++++++- apps/api/src/graphql/resolvers/documents.ts | 4 +- 6 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/db/queries/follows.ts diff --git a/apps/api/src/db/queries/documents.ts b/apps/api/src/db/queries/documents.ts index f3902c2..5c9924c 100644 --- a/apps/api/src/db/queries/documents.ts +++ b/apps/api/src/db/queries/documents.ts @@ -1,7 +1,7 @@ import { and, desc, eq, lt, ne, sql } from 'drizzle-orm' import type { DB } from '@api/db' -import { type Document, type DocumentStatus, documents } from '@api/db/schema' +import { type Document, type DocumentStatus, documentLikes, documents } from '@api/db/schema' import { slugifyString } from '@api/db/utils/slugify' import { generateId } from '@api/lib/utils' import type { UpsertDocumentData } from '@api/schemas' @@ -249,12 +249,16 @@ type PublishedArticlesOptions = { limit?: number // Optionally scope the listing to a single writer (e.g. per-writer feeds). writerId?: string + // 'latest' (default) orders by publish date; 'popular' by like count. + sort?: 'latest' | 'popular' } +export type PublishedArticle = Omit & { likeCount: number } + export const getPublishedArticles = async ( db: DB, options: PublishedArticlesOptions = {} -): Promise[]> => { +): Promise => { const safeLimit = Math.min(options.limit ?? DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE) const filters = [eq(documents.status, 'PUBLISHED')] @@ -262,6 +266,12 @@ export const getPublishedArticles = async ( filters.push(eq(documents.userId, options.writerId)) } + const likeCount = sql`count(${documentLikes.id})`.as('like_count') + const orderBy = + options.sort === 'popular' + ? [desc(likeCount), desc(documents.publishedDate)] + : [desc(documents.publishedDate)] + const publishedArticles = await db .select({ id: documents.id, @@ -283,10 +293,13 @@ export const getPublishedArticles = async ( readingTime: documents.readingTime, wordCount: documents.wordCount, isFeatured: documents.isFeatured, + likeCount, }) .from(documents) + .leftJoin(documentLikes, eq(documentLikes.documentId, documents.id)) .where(and(...filters)) - .orderBy(desc(documents.publishedDate)) + .groupBy(documents.id) + .orderBy(...orderBy) .limit(safeLimit) return publishedArticles diff --git a/apps/api/src/db/queries/follows.ts b/apps/api/src/db/queries/follows.ts new file mode 100644 index 0000000..d72d5ff --- /dev/null +++ b/apps/api/src/db/queries/follows.ts @@ -0,0 +1,144 @@ +import { and, desc, eq, lt, sql } from 'drizzle-orm' + +import type { DB } from '@api/db' +import { follows, user } from '@api/db/schema' +import { generateId } from '@api/lib/utils' + +export type FollowUserSummary = { + id: string + name: string + username: string | null + image: string | null + bio: string | null + createdAt: Date | null +} + +/** + * Follow a user. Idempotent — re-following is a no-op thanks to the + * (followerId, followingId) unique constraint. + */ +export const followUser = async (db: DB, followerId: string, followingId: string) => { + await db + .insert(follows) + .values({ + id: generateId('flw'), + followerId, + followingId, + }) + .onConflictDoNothing({ target: [follows.followerId, follows.followingId] }) +} + +/** + * Unfollow a user. Safe to call when no follow row exists. + */ +export const unfollowUser = async (db: DB, followerId: string, followingId: string) => { + await db + .delete(follows) + .where(and(eq(follows.followerId, followerId), eq(follows.followingId, followingId))) +} + +export const getFollowerCount = async (db: DB, userId: string): Promise => { + const [row] = await db + .select({ count: sql`count(*)` }) + .from(follows) + .where(eq(follows.followingId, userId)) + return row?.count ?? 0 +} + +export const getFollowingCount = async (db: DB, userId: string): Promise => { + const [row] = await db + .select({ count: sql`count(*)` }) + .from(follows) + .where(eq(follows.followerId, userId)) + return row?.count ?? 0 +} + +/** + * Whether `viewerId` follows `userId`. Returns false for anonymous viewers. + */ +export const isFollowing = async ( + db: DB, + viewerId: string | undefined, + userId: string +): Promise => { + if (!viewerId) return false + const [row] = await db + .select({ id: follows.id }) + .from(follows) + .where(and(eq(follows.followerId, viewerId), eq(follows.followingId, userId))) + .limit(1) + return Boolean(row) +} + +const PAGE_SIZE = 20 +const MAX_PAGE_SIZE = 100 + +type PageOptions = { limit?: number; cursor?: string } + +const summaryColumns = { + id: user.id, + name: user.name, + username: user.username, + image: user.image, + bio: user.bio, + createdAt: follows.createdAt, +} + +/** + * Users who follow `userId` (most recent first), cursor-paginated on follow time. + */ +export const getFollowers = async ( + db: DB, + userId: string, + options: PageOptions = {} +): Promise<{ users: FollowUserSummary[]; nextCursor: string | null }> => { + const limit = Math.min(options.limit ?? PAGE_SIZE, MAX_PAGE_SIZE) + const filters = [eq(follows.followingId, userId)] + if (options.cursor) { + filters.push(lt(follows.createdAt, new Date(options.cursor))) + } + + const rows = await db + .select(summaryColumns) + .from(follows) + .innerJoin(user, eq(follows.followerId, user.id)) + .where(and(...filters)) + .orderBy(desc(follows.createdAt)) + .limit(limit + 1) + + return paginate(rows, limit) +} + +/** + * Users that `userId` follows (most recent first), cursor-paginated on follow time. + */ +export const getFollowing = async ( + db: DB, + userId: string, + options: PageOptions = {} +): Promise<{ users: FollowUserSummary[]; nextCursor: string | null }> => { + const limit = Math.min(options.limit ?? PAGE_SIZE, MAX_PAGE_SIZE) + const filters = [eq(follows.followerId, userId)] + if (options.cursor) { + filters.push(lt(follows.createdAt, new Date(options.cursor))) + } + + const rows = await db + .select(summaryColumns) + .from(follows) + .innerJoin(user, eq(follows.followingId, user.id)) + .where(and(...filters)) + .orderBy(desc(follows.createdAt)) + .limit(limit + 1) + + return paginate(rows, limit) +} + +function paginate(rows: FollowUserSummary[], limit: number) { + let nextCursor: string | null = null + if (rows.length > limit) { + const next = rows.pop() + nextCursor = next?.createdAt ? next.createdAt.toISOString() : null + } + return { users: rows, nextCursor } +} diff --git a/apps/api/src/db/queries/index.ts b/apps/api/src/db/queries/index.ts index 5281229..5d55c84 100644 --- a/apps/api/src/db/queries/index.ts +++ b/apps/api/src/db/queries/index.ts @@ -3,6 +3,7 @@ export * from './campaigns' export * from './categories' export * from './comments' export * from './documents' +export * from './follows' export * from './likes' export * from './newsletter-settings' export * from './oauth-applications' diff --git a/apps/api/src/db/queries/subscribers.ts b/apps/api/src/db/queries/subscribers.ts index 03374b3..176a76f 100644 --- a/apps/api/src/db/queries/subscribers.ts +++ b/apps/api/src/db/queries/subscribers.ts @@ -116,6 +116,24 @@ export const getSubscriberByEmail = async ( return subscriber } +/** + * Get a subscriber scoped to a writer. Emails are unique per writer (not + * globally), so subscribe/confirm flows must look up by (writerId, email). + */ +export const getSubscriberByWriterAndEmail = async ( + db: DB, + writerId: string, + email: string +): Promise => { + const [subscriber] = await db + .select() + .from(subscribers) + .where(and(eq(subscribers.writerId, writerId), eq(subscribers.email, email))) + .limit(1) + + return subscriber +} + /** * Get subscriber by token */ diff --git a/apps/api/src/db/queries/users.ts b/apps/api/src/db/queries/users.ts index 68f4a56..4210357 100644 --- a/apps/api/src/db/queries/users.ts +++ b/apps/api/src/db/queries/users.ts @@ -1,9 +1,135 @@ -import { eq } from 'drizzle-orm' +import { and, eq, ne, sql } from 'drizzle-orm' import type { DB } from '@api/db' -import { user } from '@api/db/schema' +import { documents, user } from '@api/db/schema' +import { slugifyString } from '@api/db/utils/slugify' +import { getFollowerCount, getFollowingCount, isFollowing } from './follows' export async function getUserById(db: DB, id: string) { const [userDB] = await db.select().from(user).where(eq(user.id, id)).limit(1) return userDB } + +export async function getUserByUsername(db: DB, username: string) { + const [userDB] = await db.select().from(user).where(eq(user.username, username)).limit(1) + return userDB +} + +export type PublicProfile = { + id: string + name: string + username: string | null + image: string | null + bio: string | null + website: string | null + location: string | null + socialLinks: { + twitter?: string + github?: string + linkedin?: string + website?: string + } | null + createdAt: Date | null + followerCount: number + followingCount: number + postCount: number + isFollowing: boolean +} + +/** + * Resolve a public profile by handle, including follower/following/post counts + * and whether the (optional) viewer already follows the author. + */ +export async function getPublicProfile( + db: DB, + username: string, + viewerId?: string +): Promise { + const userDB = await getUserByUsername(db, username) + if (!userDB) return undefined + + const [followerCount, followingCount, postCount, viewerFollows] = await Promise.all([ + getFollowerCount(db, userDB.id), + getFollowingCount(db, userDB.id), + getPublishedPostCount(db, userDB.id), + isFollowing(db, viewerId, userDB.id), + ]) + + return { + id: userDB.id, + name: userDB.name, + username: userDB.username, + image: userDB.image, + bio: userDB.bio, + website: userDB.website, + location: userDB.location, + socialLinks: userDB.socialLinks ?? null, + createdAt: userDB.createdAt, + followerCount, + followingCount, + postCount, + isFollowing: viewerFollows, + } +} + +export async function getPublishedPostCount(db: DB, userId: string): Promise { + const [row] = await db + .select({ count: sql`count(*)` }) + .from(documents) + .where(and(eq(documents.userId, userId), eq(documents.status, 'PUBLISHED'))) + return row?.count ?? 0 +} + +/** + * Whether `username` is free (optionally excluding the user already holding it). + */ +export async function isUsernameAvailable( + db: DB, + username: string, + excludeUserId?: string +): Promise { + const where = excludeUserId + ? and(eq(user.username, username), ne(user.id, excludeUserId)) + : eq(user.username, username) + const [existing] = await db.select({ id: user.id }).from(user).where(where).limit(1) + return !existing +} + +/** + * Derive a unique, slug-safe handle from a base string (name or email local + * part), appending an incremental suffix on collision — mirrors how document + * slugs dedupe in queries/documents.ts. + */ +export async function ensureUniqueUsername(db: DB, base: string): Promise { + const root = slugifyString(base).slice(0, 24) || 'user' + let candidate = root + let suffix = 0 + while (true) { + if (await isUsernameAvailable(db, candidate)) return candidate + suffix += 1 + candidate = `${root}-${suffix}` + } +} + +/** + * Persist profile fields the user controls from settings. `username` uniqueness + * is enforced by the DB unique index; callers should pre-check availability. + */ +export async function updateUserProfile( + db: DB, + userId: string, + data: { + username?: string + bio?: string | null + website?: string | null + location?: string | null + socialLinks?: PublicProfile['socialLinks'] + } +) { + const [updated] = await db + .update(user) + .set({ ...data, updatedAt: new Date() }) + .where(eq(user.id, userId)) + .returning() + return updated +} diff --git a/apps/api/src/graphql/resolvers/documents.ts b/apps/api/src/graphql/resolvers/documents.ts index ab20c0a..c9526c7 100644 --- a/apps/api/src/graphql/resolvers/documents.ts +++ b/apps/api/src/graphql/resolvers/documents.ts @@ -145,7 +145,9 @@ export const documentResolvers = { context: GraphQLContext ): Promise => { const limit = Math.min(args.first ?? 20, 100) - return getPublishedArticles(context.db, { limit }) as Promise + // getPublishedArticles omits `markdown` and adds `likeCount`; GraphQL + // field resolvers don't read those, so the shape cast is safe. + return getPublishedArticles(context.db, { limit }) as unknown as Promise }, node: async (_: unknown, args: { id: string }, context: GraphQLContext) => { From 8d38ea521387d233154bf16f9c27a3415dd6253e Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:53 +0200 Subject: [PATCH 3/7] feat(api): add profile and follows validation schemas Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/schemas/follows.ts | 19 +++++++++++++++++ apps/api/src/schemas/index.ts | 1 + apps/api/src/schemas/users.ts | 36 +++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 apps/api/src/schemas/follows.ts diff --git a/apps/api/src/schemas/follows.ts b/apps/api/src/schemas/follows.ts new file mode 100644 index 0000000..536c68a --- /dev/null +++ b/apps/api/src/schemas/follows.ts @@ -0,0 +1,19 @@ +import { z } from '@hono/zod-openapi' + +export const followUserSchema = z.object({ + // The user being followed/unfollowed. + userId: z.string(), +}) + +export const followStatusSchema = z.object({ + userId: z.string(), +}) + +export const followListSchema = z.object({ + // Whose followers / following list to page through. + userId: z.string(), + limit: z.number().int().min(1).max(100).optional(), + cursor: z.string().optional(), +}) + +export type FollowUserData = z.infer diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts index c25a83a..ce8946f 100644 --- a/apps/api/src/schemas/index.ts +++ b/apps/api/src/schemas/index.ts @@ -3,6 +3,7 @@ export * from './categories' export * from './comments' export * from './documents' export * from './errors' +export * from './follows' export * from './likes' export * from './subscribers' export * from './tags' diff --git a/apps/api/src/schemas/users.ts b/apps/api/src/schemas/users.ts index 51d0a81..fb8d6f7 100644 --- a/apps/api/src/schemas/users.ts +++ b/apps/api/src/schemas/users.ts @@ -11,3 +11,39 @@ export const userProfileSchema = z updatedAt: z.date(), }) .openapi('UserProfile') + +// Handle: lowercase letters, numbers, hyphen, underscore; 3–30 chars. +export const usernameSchema = z + .string() + .trim() + .toLowerCase() + .min(3, 'Handle must be at least 3 characters.') + .max(30, 'Handle must be at most 30 characters.') + .regex(/^[a-z0-9_-]+$/, 'Use only lowercase letters, numbers, hyphens, or underscores.') + +export const socialLinksSchema = z.object({ + twitter: z.string().trim().max(200).optional(), + github: z.string().trim().max(200).optional(), + linkedin: z.string().trim().max(200).optional(), + website: z.string().trim().max(200).optional(), +}) + +export const getProfileByUsernameSchema = z.object({ + username: usernameSchema, +}) + +export const checkUsernameSchema = z.object({ + username: usernameSchema, +}) + +export const updateSocialLinksSchema = z.object({ + socialLinks: socialLinksSchema, +}) + +export const profilePostsSchema = z.object({ + username: usernameSchema, + sort: z.enum(['latest', 'popular']).default('latest'), + limit: z.number().int().min(1).max(50).optional(), +}) + +export type SocialLinks = z.infer From dbf5db90c0e054f143e067a6ad9606717a68c954 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:53 +0200 Subject: [PATCH 4/7] feat(api): auto-assign handles and expose profile fields on session Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/lib/auth/auth.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/apps/api/src/lib/auth/auth.ts b/apps/api/src/lib/auth/auth.ts index 56ae952..d4d3b74 100644 --- a/apps/api/src/lib/auth/auth.ts +++ b/apps/api/src/lib/auth/auth.ts @@ -4,6 +4,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle' import { emailOTP, oneTimeToken } from 'better-auth/plugins' import { createDb } from '@api/db' +import { ensureUniqueUsername } from '@api/db/queries/users' import * as schema from '@api/db/schema' import { env } from '@api/env-runtime' import { sendEmail } from '@api/lib/messaging/email/mailer' @@ -21,6 +22,25 @@ export const createAuth = () => { }), secret: env.BETTER_AUTH_SECRET, trustedOrigins: [...env.ALLOWED_API_ORIGINS.split(',')], + user: { + additionalFields: { + username: { type: 'string', required: false }, + bio: { type: 'string', required: false }, + website: { type: 'string', required: false }, + location: { type: 'string', required: false }, + }, + }, + databaseHooks: { + user: { + create: { + before: async (newUser) => { + const base = newUser.name?.trim() || newUser.email.split('@')[0] || 'user' + const username = await ensureUniqueUsername(db, base) + return { data: { ...newUser, username } } + }, + }, + }, + }, advanced: { cookiePrefix: 'lemma', crossSubDomainCookies: { From 677b87b56e9850d56a5e80f511bb5b30066f98b0 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:53 +0200 Subject: [PATCH 5/7] feat(api): add profile/follows routers, public reader and subscribe Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/trpc/routers/_app.ts | 4 + apps/api/src/trpc/routers/documents.ts | 25 ++++ apps/api/src/trpc/routers/follows.ts | 51 +++++++ apps/api/src/trpc/routers/newsletter.ts | 191 ++++++++++++++---------- apps/api/src/trpc/routers/profile.ts | 95 ++++++++++++ 5 files changed, 283 insertions(+), 83 deletions(-) create mode 100644 apps/api/src/trpc/routers/follows.ts create mode 100644 apps/api/src/trpc/routers/profile.ts diff --git a/apps/api/src/trpc/routers/_app.ts b/apps/api/src/trpc/routers/_app.ts index 768948b..3556e6c 100644 --- a/apps/api/src/trpc/routers/_app.ts +++ b/apps/api/src/trpc/routers/_app.ts @@ -5,9 +5,11 @@ import { apiKeysRouter } from './api-keys' import { campaignsRouter } from './campaigns' import { commentsRouter } from './comments' import { documentRouter } from './documents' +import { followsRouter } from './follows' import { likesRouter } from './likes' import { newsletterRouter } from './newsletter' import { oauthApplicationsRouter } from './oauth-applications' +import { profileRouter } from './profile' import { workspacesRouter } from './workspaces' export const appRouter = createTRPCRouter({ @@ -19,6 +21,8 @@ export const appRouter = createTRPCRouter({ campaigns: campaignsRouter, workspaces: workspacesRouter, oauthApplications: oauthApplicationsRouter, + profile: profileRouter, + follows: followsRouter, }) // export type definition of API diff --git a/apps/api/src/trpc/routers/documents.ts b/apps/api/src/trpc/routers/documents.ts index 2017c64..568d0ce 100644 --- a/apps/api/src/trpc/routers/documents.ts +++ b/apps/api/src/trpc/routers/documents.ts @@ -1,9 +1,12 @@ import { TRPCError } from '@trpc/server' +import { z } from 'zod' import { deleteDocument, getDocumentById, + getPublishedArticleBySlug, getPublishedArticles, + getUserById, getUserDocuments, searchUserDocuments, updateDocumentBannerImage, @@ -37,6 +40,28 @@ export const documentRouter = createTRPCRouter({ return getDocumentById(db, input.id) }), + // Public reader: a PUBLISHED post by slug (or id fallback) with an author + // byline. Returns null when the post is missing or not yet published. + getPublishedBySlug: publicProcedure + .input(z.object({ slug: z.string() })) + .query(async ({ ctx: { db }, input }) => { + const doc = await getPublishedArticleBySlug(db, input.slug) + if (!doc) return null + + const author = doc.userId ? await getUserById(db, doc.userId) : undefined + return { + ...doc, + author: author + ? { + id: author.id, + name: author.name, + username: author.username, + image: author.image, + } + : null, + } + }), + upsertDocument: protectedProcedure .input(upsertDocumentSchema) .mutation(async ({ ctx, input }) => { diff --git a/apps/api/src/trpc/routers/follows.ts b/apps/api/src/trpc/routers/follows.ts new file mode 100644 index 0000000..9e55e77 --- /dev/null +++ b/apps/api/src/trpc/routers/follows.ts @@ -0,0 +1,51 @@ +import { TRPCError } from '@trpc/server' + +import { + followUser, + getFollowerCount, + getFollowers, + getFollowing, + getUserById, + isFollowing, + unfollowUser, +} from '@api/db/queries' +import { followListSchema, followStatusSchema, followUserSchema } from '@api/schemas' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../init' + +export const followsRouter = createTRPCRouter({ + follow: protectedProcedure.input(followUserSchema).mutation(async ({ ctx, input }) => { + if (input.userId === ctx.user.id) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'You cannot follow yourself.' }) + } + const target = await getUserById(ctx.db, input.userId) + if (!target) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }) + } + await followUser(ctx.db, ctx.user.id, input.userId) + const followerCount = await getFollowerCount(ctx.db, input.userId) + return { isFollowing: true, followerCount } + }), + + unfollow: protectedProcedure.input(followUserSchema).mutation(async ({ ctx, input }) => { + await unfollowUser(ctx.db, ctx.user.id, input.userId) + const followerCount = await getFollowerCount(ctx.db, input.userId) + return { isFollowing: false, followerCount } + }), + + // Public: anonymous viewers always get isFollowing=false. + status: publicProcedure.input(followStatusSchema).query(async ({ ctx, input }) => { + const [following, followerCount] = await Promise.all([ + isFollowing(ctx.db, ctx.user?.id, input.userId), + getFollowerCount(ctx.db, input.userId), + ]) + return { isFollowing: following, followerCount } + }), + + followers: publicProcedure.input(followListSchema).query(async ({ ctx, input }) => { + return getFollowers(ctx.db, input.userId, { limit: input.limit, cursor: input.cursor }) + }), + + following: publicProcedure.input(followListSchema).query(async ({ ctx, input }) => { + return getFollowing(ctx.db, input.userId, { limit: input.limit, cursor: input.cursor }) + }), +}) diff --git a/apps/api/src/trpc/routers/newsletter.ts b/apps/api/src/trpc/routers/newsletter.ts index 6277aa7..695c23a 100644 --- a/apps/api/src/trpc/routers/newsletter.ts +++ b/apps/api/src/trpc/routers/newsletter.ts @@ -1,22 +1,101 @@ import { TRPCError } from '@trpc/server' import { z } from 'zod' +import type { DB } from '@api/db' import { getWriterNewsletterSettings, upsertWriterNewsletterSettings, } from '@api/db/queries/newsletter-settings' import { getConfirmedSubscribers, - getSubscriberByEmail, getSubscriberByToken, + getSubscriberByWriterAndEmail, upsertSubscriber, } from '@api/db/queries/subscribers' +import { getUserByUsername } from '@api/db/queries/users' import { unsubscribeEvents } from '@api/db/schema' import { enqueueConfirmationEmail, enqueueWelcomeEmail } from '@api/jobs/producers' import { generateId } from '@api/lib/utils' import { newsletterSettingsSchema } from '@api/schemas/newsletter' import { createTRPCRouter, protectedProcedure, publicProcedure } from '@api/trpc/init' +/** + * Shared subscribe logic, scoped to a writer. Used by both the protected + * `subscribe` (writerId = the authed writer) and the public `subscribePublic` + * (writerId resolved from a profile handle). Emails are unique per writer, so + * the same email can subscribe to many writers. + */ +async function subscribeToWriter( + db: DB, + writerId: string, + email: string, + sendConfirmation: boolean +): Promise<{ success: boolean; message: string }> { + const existingSub = await getSubscriberByWriterAndEmail(db, writerId, email) + + if (existingSub) { + if (!existingSub.isConfirmed && sendConfirmation) { + const writerSettings = await getWriterNewsletterSettings(db, writerId) + if (writerSettings) { + await enqueueConfirmationEmail({ + subscriberId: existingSub.id, + email: existingSub.email, + token: existingSub.token, + writerId, + writerSettings: { + id: writerSettings.id, + newsletterName: writerSettings.newsletterName, + fromName: writerSettings.fromName, + logoUrl: writerSettings.logoUrl, + brandColor: writerSettings.brandColor, + confirmationUrl: writerSettings.confirmationUrl, + }, + }) + } + return { success: true, message: 'Confirmation email resent' } + } + + throw new TRPCError({ message: 'Already joined the newsletter.', code: 'CONFLICT' }) + } + + const writerSettings = await getWriterNewsletterSettings(db, writerId) + if (!writerSettings) { + throw new TRPCError({ message: 'Writer newsletter not found.', code: 'NOT_FOUND' }) + } + + const subCreated = await upsertSubscriber(db, { + email, + token: generateId('st'), + writerId, + }) + + if (!subCreated) { + throw new TRPCError({ message: 'Subscription not created', code: 'INTERNAL_SERVER_ERROR' }) + } + + if (sendConfirmation) { + await enqueueWelcomeEmail( + { + subscriberId: subCreated.id, + email: subCreated.email, + token: subCreated.token, + writerId, + writerSettings: { + id: writerSettings.id, + newsletterName: writerSettings.newsletterName, + fromName: writerSettings.fromName, + logoUrl: writerSettings.logoUrl, + brandColor: writerSettings.brandColor, + confirmationUrl: writerSettings.confirmationUrl, + }, + }, + { delay: 0, priority: 9 } + ) + } + + return { success: true, message: 'Subscribed successfully' } +} + export const newsletterRouter = createTRPCRouter({ getWriterNewsletterSettings: protectedProcedure.query(async ({ ctx }) => { const settings = await getWriterNewsletterSettings(ctx.db, ctx.user.id) @@ -41,9 +120,8 @@ export const newsletterRouter = createTRPCRouter({ }), /** - * Subscribe to the newsletter - * protected because we need to know the writerId - * send the api key or auth cookie when calling this endpoint + * Subscribe to the authed writer's own newsletter (e.g. API-key/cURL usage). + * The writer is taken from the session. */ subscribe: protectedProcedure .input( @@ -53,89 +131,36 @@ export const newsletterRouter = createTRPCRouter({ }) ) .mutation(async ({ ctx, input }) => { - const existingSub = await getSubscriberByEmail(ctx.db, input.email) - - if (existingSub) { - // If already subscribed but not confirmed, resend confirmation - if (!existingSub.isConfirmed && input.sendConfirmation) { - const writerSettings = await getWriterNewsletterSettings(ctx.db, ctx.user.id) - if (writerSettings) { - await enqueueConfirmationEmail({ - subscriberId: existingSub.id, - email: existingSub.email, - token: existingSub.token, - writerId: ctx.user.id, - writerSettings: { - id: writerSettings.id, - newsletterName: writerSettings.newsletterName, - fromName: writerSettings.fromName, - logoUrl: writerSettings.logoUrl, - brandColor: writerSettings.brandColor, - confirmationUrl: writerSettings.confirmationUrl, - }, - }) - } - return { success: true, message: 'Confirmation email resent' } - } - - throw new TRPCError({ - message: 'Already joined the newsletter.', - code: 'CONFLICT', - }) - } - - const writerSettings = await getWriterNewsletterSettings(ctx.db, ctx.user.id) + return subscribeToWriter(ctx.db, ctx.user.id, input.email, input.sendConfirmation) + }), - if (!writerSettings) { - throw new TRPCError({ - message: 'Writer newsletter not found.', - code: 'NOT_FOUND', + /** + * Public newsletter signup from an author's profile. A logged-out visitor + * subscribes to the writer identified by handle (or id). + */ + subscribePublic: publicProcedure + .input( + z + .object({ + username: z.string().optional(), + writerId: z.string().optional(), + email: z.email(), + sendConfirmation: z.boolean().optional().default(true), }) - } - - try { - const subCreated = await upsertSubscriber(ctx.db, { - email: input.email, - token: generateId('st'), - writerId: ctx.user.id, + .refine((v) => v.username || v.writerId, { + message: 'A writer handle or id is required.', }) - - if (!subCreated) { - throw new TRPCError({ - message: 'Subscription not created', - code: 'INTERNAL_SERVER_ERROR', - }) - } - - // Send welcome/confirmation email via job queue - if (input.sendConfirmation) { - await enqueueWelcomeEmail( - { - subscriberId: subCreated.id, - email: subCreated.email, - token: subCreated.token, - writerId: ctx.user.id, - writerSettings: { - id: writerSettings.id, - newsletterName: writerSettings.newsletterName, - fromName: writerSettings.fromName, - logoUrl: writerSettings.logoUrl, - brandColor: writerSettings.brandColor, - confirmationUrl: writerSettings.confirmationUrl, - }, - }, - { delay: 0, priority: 9 } - ) + ) + .mutation(async ({ ctx, input }) => { + let writerId = input.writerId + if (!writerId && input.username) { + const writer = await getUserByUsername(ctx.db, input.username) + if (!writer) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Writer not found' }) } - - return { success: true, message: 'Subscribed successfully' } - } catch (error) { - console.error('Subscription error:', error) - throw new TRPCError({ - message: 'Something went wrong', - code: 'INTERNAL_SERVER_ERROR', - }) + writerId = writer.id } + return subscribeToWriter(ctx.db, writerId as string, input.email, input.sendConfirmation) }), unsubscribe: publicProcedure @@ -226,7 +251,7 @@ export const newsletterRouter = createTRPCRouter({ resendConfirmation: protectedProcedure .input(z.object({ email: z.email() })) .mutation(async ({ ctx, input }) => { - const sub = await getSubscriberByEmail(ctx.db, input.email) + const sub = await getSubscriberByWriterAndEmail(ctx.db, ctx.user.id, input.email) if (!sub) { throw new TRPCError({ diff --git a/apps/api/src/trpc/routers/profile.ts b/apps/api/src/trpc/routers/profile.ts new file mode 100644 index 0000000..cf5fbe1 --- /dev/null +++ b/apps/api/src/trpc/routers/profile.ts @@ -0,0 +1,95 @@ +import { TRPCError } from '@trpc/server' + +import { + ensureUniqueUsername, + getPublicProfile, + getPublishedArticles, + getUserById, + getUserByUsername, + isUsernameAvailable, + updateUserProfile, +} from '@api/db/queries' +import { + checkUsernameSchema, + getProfileByUsernameSchema, + profilePostsSchema, + updateSocialLinksSchema, +} from '@api/schemas' +import { createTRPCRouter, protectedProcedure, publicProcedure } from '../init' + +export const profileRouter = createTRPCRouter({ + // Public author profile resolved by handle, with follow/post counts and + // whether the current viewer (if any) already follows the author. + getByUsername: publicProcedure.input(getProfileByUsernameSchema).query(async ({ ctx, input }) => { + const profile = await getPublicProfile(ctx.db, input.username, ctx.user?.id) + if (!profile) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found' }) + } + return profile + }), + + // Published posts for a handle (Latest / Popular tabs). + posts: publicProcedure.input(profilePostsSchema).query(async ({ ctx, input }) => { + const author = await getUserByUsername(ctx.db, input.username) + if (!author) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Profile not found' }) + } + return getPublishedArticles(ctx.db, { + writerId: author.id, + sort: input.sort, + limit: input.limit, + }) + }), + + // Current user's editable profile fields (for the settings form). + me: protectedProcedure.query(async ({ ctx }) => { + const me = await getUserById(ctx.db, ctx.user.id) + if (!me) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }) + } + return { + id: me.id, + name: me.name, + image: me.image, + username: me.username, + bio: me.bio, + website: me.website, + location: me.location, + socialLinks: me.socialLinks ?? null, + } + }), + + // Debounced availability check for the handle field in settings. + checkUsername: protectedProcedure.input(checkUsernameSchema).query(async ({ ctx, input }) => { + const available = await isUsernameAvailable(ctx.db, input.username, ctx.user.id) + return { available } + }), + + // Backfill a handle for accounts created before handles existed. Idempotent. + ensureUsername: protectedProcedure.mutation(async ({ ctx }) => { + const me = await getUserById(ctx.db, ctx.user.id) + if (!me) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found' }) + } + if (me.username) { + return { username: me.username } + } + const base = me.name?.trim() || me.email.split('@')[0] || 'user' + const username = await ensureUniqueUsername(ctx.db, base) + const updated = await updateUserProfile(ctx.db, ctx.user.id, { username }) + return { username: updated?.username ?? username } + }), + + // socialLinks is a JSON column, so it is written here rather than via + // better-auth's scalar additionalFields. + updateSocialLinks: protectedProcedure + .input(updateSocialLinksSchema) + .mutation(async ({ ctx, input }) => { + // Drop empty strings so cleared fields are stored as absent. + const cleaned = Object.fromEntries( + Object.entries(input.socialLinks).filter(([, v]) => v && v.length > 0) + ) + const updated = await updateUserProfile(ctx.db, ctx.user.id, { socialLinks: cleaned }) + return { socialLinks: updated?.socialLinks ?? null } + }), +}) From 111417bcc5e881150eadbb56e80edd4639bb7942 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 20:59:53 +0200 Subject: [PATCH 6/7] fix(api): scope newsletter subscriber lookups to the writer Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/src/graphql/resolvers/newsletter.ts | 5 +++-- apps/api/src/rest/routers/newsletter.ts | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/graphql/resolvers/newsletter.ts b/apps/api/src/graphql/resolvers/newsletter.ts index bb4097f..bedd2b7 100644 --- a/apps/api/src/graphql/resolvers/newsletter.ts +++ b/apps/api/src/graphql/resolvers/newsletter.ts @@ -1,5 +1,5 @@ import { GraphQLError } from 'graphql' -import { eq } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import { subscribers, type Subscriber } from '@api/db/schema' import { generateId } from '@api/lib/utils' @@ -22,10 +22,11 @@ type ConfirmSubscriptionInput = { } async function getSubscriberByEmail(db: any, email: string, writerId: string) { + // Emails are unique per writer, so scope the lookup to this writer. const [subscriber] = await db .select() .from(subscribers) - .where(eq(subscribers.email, email)) + .where(and(eq(subscribers.email, email), eq(subscribers.writerId, writerId))) .limit(1) return subscriber diff --git a/apps/api/src/rest/routers/newsletter.ts b/apps/api/src/rest/routers/newsletter.ts index d6f97ca..ab55e54 100644 --- a/apps/api/src/rest/routers/newsletter.ts +++ b/apps/api/src/rest/routers/newsletter.ts @@ -3,8 +3,8 @@ import { HTTPException } from 'hono/http-exception' import { getWriterNewsletterSettings } from '@api/db/queries/newsletter-settings' import { - getSubscriberByEmail, getSubscriberByToken, + getSubscriberByWriterAndEmail, upsertSubscriber, } from '@api/db/queries/subscribers' import { unsubscribeEvents } from '@api/db/schema' @@ -58,7 +58,7 @@ newsletterRouter.openapi( const session = c.get('session') const input = c.req.valid('json') as { email: string; sendConfirmation?: boolean } - const existing = await getSubscriberByEmail(db, input.email) + const existing = await getSubscriberByWriterAndEmail(db, session.user.id, input.email) if (existing) { // If already subscribed but not confirmed, resend confirmation From 7b1cae729e5efd133a853003d2b74b978fc11943 Mon Sep 17 00:00:00 2001 From: irere123 Date: Wed, 17 Jun 2026 21:09:12 +0200 Subject: [PATCH 7/7] feat(web): add public profiles UI Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/web/src/components/dropdowns/account.tsx | 34 ++- .../components/profile/article-content.tsx | 34 +++ .../components/profile/article-engagement.tsx | 160 ++++++++++++ .../components/profile/profile-actions.tsx | 184 ++++++++++++++ .../src/components/profile/profile-header.tsx | 164 +++++++++++++ .../src/components/profile/profile-posts.tsx | 97 ++++++++ apps/web/src/lib/auth-client.ts | 17 +- apps/web/src/routeTree.gen.ts | 84 +++++-- apps/web/src/routes/app/settings/profile.tsx | 229 ++++++++++++++++++ apps/web/src/routes/app/settings/route.tsx | 1 + apps/web/src/routes/u/$username.tsx | 72 ------ apps/web/src/routes/u/$username/$slug.tsx | 119 +++++++++ apps/web/src/routes/u/$username/index.tsx | 65 +++++ 13 files changed, 1162 insertions(+), 98 deletions(-) create mode 100644 apps/web/src/components/profile/article-content.tsx create mode 100644 apps/web/src/components/profile/article-engagement.tsx create mode 100644 apps/web/src/components/profile/profile-actions.tsx create mode 100644 apps/web/src/components/profile/profile-header.tsx create mode 100644 apps/web/src/components/profile/profile-posts.tsx create mode 100644 apps/web/src/routes/app/settings/profile.tsx delete mode 100644 apps/web/src/routes/u/$username.tsx create mode 100644 apps/web/src/routes/u/$username/$slug.tsx create mode 100644 apps/web/src/routes/u/$username/index.tsx diff --git a/apps/web/src/components/dropdowns/account.tsx b/apps/web/src/components/dropdowns/account.tsx index 51eeb50..13e3d72 100644 --- a/apps/web/src/components/dropdowns/account.tsx +++ b/apps/web/src/components/dropdowns/account.tsx @@ -6,10 +6,14 @@ import { IconMessageCircle, IconUserCircle, } from '@tabler/icons-react' -import { Link } from '@tanstack/react-router' +import { useMutation } from '@tanstack/react-query' +import { Link, useNavigate } from '@tanstack/react-router' import type { ComponentProps, ReactElement, ReactNode } from 'react' +import { useEffect } from 'react' import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from '@/components/ui/menu' +import { signOut, useSession } from '@/lib/auth-client' +import { useTRPC } from '@/trpc/client' import { Button } from '../ui/button' export function AccountDropdown({ @@ -25,6 +29,25 @@ export function AccountDropdown({ side?: ComponentProps['side'] align?: ComponentProps['align'] }) { + const { data: session, refetch } = useSession() + const navigate = useNavigate() + const trpc = useTRPC() + const username = session?.user?.username ?? null + + const ensureUsername = useMutation( + trpc.profile.ensureUsername.mutationOptions({ + onSuccess: () => refetch(), + }) + ) + + // Backfill a handle for accounts created before handles existed, so the + // profile link works for everyone. + useEffect(() => { + if (session?.user && !session.user.username && !ensureUsername.isPending) { + ensureUsername.mutate(undefined) + } + }, [session?.user, ensureUsername]) + const trigger = children ? ( + ) +} + +type CommentItem = { + id: string + content: string + createdAt: Date | null + author: { id: string; name: string | null; image: string | null } +} + +function formatWhen(date: Date | null) { + if (!date) return '' + return new Date(date).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) +} + +export function Comments({ documentId }: { documentId: string }) { + const trpc = useTRPC() + const queryClient = useQueryClient() + const { data: session } = useSession() + const [content, setContent] = useState('') + + const commentsQuery = trpc.comments.getComments.queryOptions({ documentId, limit: 20 }) + const { data, isLoading } = useQuery(commentsQuery) + + const create = useMutation( + trpc.comments.create.mutationOptions({ + onSuccess: () => { + setContent('') + queryClient.invalidateQueries({ queryKey: commentsQuery.queryKey }) + }, + onError: (err) => toast.error(err.message || 'Could not post comment'), + }) + ) + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + if (!content.trim()) return + create.mutate({ documentId, content: content.trim() }) + } + + const comments = (data?.comments ?? []) as CommentItem[] + + return ( +
+

+ Comments {comments.length > 0 && ({comments.length})} +

+ + {session?.user ? ( +
+