diff --git a/bun.lockb b/bun.lockb index ebf79ef3..807725d7 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index fa5799a1..524f6547 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@edge-runtime/cookies": "^4.1.1", "@headlessui/react": "^1.7.17", "@hookform/resolvers": "^3.3.4", - "@react-email/render": "^0.0.10", + "@react-email/render": "^1.0.1", "@supabase/ssr": "^0.3.0", "@supabase/supabase-js": "^2.42.0", "@t3-oss/env-nextjs": "^0.9.2", diff --git a/src/email/sendEmail.tsx b/src/email/sendEmail.tsx new file mode 100644 index 00000000..55aa3419 --- /dev/null +++ b/src/email/sendEmail.tsx @@ -0,0 +1,42 @@ +import MagicLinkTemplate from "~/email/templates/magic-link"; +import JoinWorkspaceTemplate from "~/email/templates/join-workspace"; + +import { render } from "@react-email/render"; + +type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE"; + +const emailTemplates: Record = { + MAGIC_LINK: MagicLinkTemplate, + JOIN_WORKSPACE: JoinWorkspaceTemplate, +}; + +export const sendEmail = async ( + to: string, + subject: string, + template: Templates, + data: Record, +) => { + const EmailTemplate = emailTemplates[template]; + + const html = await render(, { pretty: true }); + + const response = await fetch(process.env.EMAIL_URL!, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${process.env.EMAIL_TOKEN}`, + }, + body: JSON.stringify({ + from: process.env.EMAIL_FROM, + to, + subject, + html, + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send email: ${response.statusText}`); + } + + return response; +}; diff --git a/src/email/templates/join-workspace.tsx b/src/email/templates/join-workspace.tsx new file mode 100644 index 00000000..b2d56f02 --- /dev/null +++ b/src/email/templates/join-workspace.tsx @@ -0,0 +1,100 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Html, + Hr, + Link, + Preview, + Tailwind, + Text, +} from "@react-email/components"; + +import * as React from "react"; + +export const JoinWorkspaceTemplate = ({ + magicLoginUrl, +}: { + magicLoginUrl?: string; +}) => ( + + + + Log in with this magic link + + + + kan.bn + + + Login to your Kan account + + + Click the button below to instantly login to your account. + + + + If you didn't try to login, you can safely ignore this email. + +
+ + + Kan + + , the open source Trello alternative. + +
+ + +
+); + +export default JoinWorkspaceTemplate; diff --git a/src/email/emails/magic-link.tsx b/src/email/templates/magic-link.tsx similarity index 95% rename from src/email/emails/magic-link.tsx rename to src/email/templates/magic-link.tsx index ed0304b8..99cf01f3 100644 --- a/src/email/emails/magic-link.tsx +++ b/src/email/templates/magic-link.tsx @@ -18,7 +18,7 @@ interface MagicLinkEmailProps { loginUrl?: string; } -export const MagicLinkEmail = ({ loginUrl }: MagicLinkEmailProps) => ( +export const MagicLinkTemplate = ({ loginUrl }: MagicLinkEmailProps) => ( ( ); -export default MagicLinkEmail; +export default MagicLinkTemplate; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index f5282ca5..de59247e 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -3,6 +3,7 @@ import { boardRouter } from "~/server/api/routers/board"; import { cardRouter } from "~/server/api/routers/card"; import { labelRouter } from "~/server/api/routers/label"; import { listRouter } from "~/server/api/routers/list"; +import { memberRouter } from "~/server/api/routers/member"; import { importRouter } from "~/server/api/routers/import"; import { workspaceRouter } from "~/server/api/routers/workspace"; import { createTRPCRouter } from "~/server/api/trpc"; @@ -18,6 +19,7 @@ export const appRouter = createTRPCRouter({ card: cardRouter, label: labelRouter, list: listRouter, + member: memberRouter, import: importRouter, workspace: workspaceRouter, }); diff --git a/src/server/api/routers/member.ts b/src/server/api/routers/member.ts new file mode 100644 index 00000000..6130d8a4 --- /dev/null +++ b/src/server/api/routers/member.ts @@ -0,0 +1,128 @@ +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; + +import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"; + +import * as workspaceRepo from "~/server/db/repository/workspace.repo"; +import * as memberRepo from "~/server/db/repository/member.repo"; +import * as userRepo from "~/server/db/repository/user.repo"; + +import { sendEmail } from "~/email/sendEmail"; + +export const memberRouter = createTRPCRouter({ + invite: protectedProcedure + .input( + z.object({ + email: z.string().email(), + workspacePublicId: z.string().min(12), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.user?.id; + + if (!userId) + throw new TRPCError({ + message: `User not authenticated`, + code: "UNAUTHORIZED", + }); + + const workspace = await workspaceRepo.getByPublicIdWithMembers( + ctx.db, + input.workspacePublicId, + ); + + if (!workspace) + throw new TRPCError({ + message: `Workspace with public ID ${input.workspacePublicId} not found`, + code: "NOT_FOUND", + }); + + const isInvitedEmailAlreadyMember = workspace?.members.some( + (member) => member.user?.email === input.email, + ); + + if (isInvitedEmailAlreadyMember) { + throw new TRPCError({ + message: `User with email ${input.email} is already a member of this workspace`, + code: "BAD_REQUEST", + }); + } + + let invitedUserId: string | null = null; + + const existingUser = await userRepo.getByEmail(ctx.adminDb, input.email); + + if (existingUser) { + invitedUserId = existingUser.id; + + const magicLink = await ctx.adminDb.auth.admin.generateLink({ + type: "magiclink", + email: input.email, + }); + + const magicLinkUrl = magicLink.data.properties?.action_link; + + if (!magicLinkUrl) + throw new TRPCError({ + message: `Unable to generate magic link for user with email ${input.email}`, + code: "INTERNAL_SERVER_ERROR", + }); + + await sendEmail( + input.email, + "Invitation to join workspace", + "JOIN_WORKSPACE", + { + magicLinkUrl, + }, + ); + } else { + const invite = await ctx.adminDb.auth.admin.generateLink({ + type: "invite", + email: input.email, + }); + + const magicLinkUrl = invite.data.properties?.action_link; + + if (!magicLinkUrl) + throw new TRPCError({ + message: `Unable to generate invite link for user with email ${input.email}`, + code: "INTERNAL_SERVER_ERROR", + }); + + await sendEmail( + input.email, + "Invitation to join workspace", + "JOIN_WORKSPACE", + { + magicLinkUrl, + }, + ); + + invitedUserId = invite.data.user?.id ?? null; + const invitedUserEmail = invite.data.user?.email; + + if (invitedUserId && invitedUserEmail) + userRepo.create(ctx.adminDb, { + email: invitedUserEmail, + id: invitedUserId, + }); + } + + if (!invitedUserId) + throw new TRPCError({ + message: `Unable to invite user with email ${input.email}`, + code: "INTERNAL_SERVER_ERROR", + }); + + const invite = await memberRepo.create(ctx.db, { + workspaceId: workspace.id, + userId: invitedUserId, + createdBy: userId, + role: "member", + status: "invited", + }); + + return invite; + }), +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index ad1f8701..b5ef9d07 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -11,7 +11,7 @@ import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"; import superjson from "superjson"; import { ZodError } from "zod"; -import { createTRPCClient } from "~/utils/supabase/api"; +import { createTRPCClient, createTRPCAdminClient } from "~/utils/supabase/api"; import { type Database } from "~/types/database.types"; import { type SupabaseClient } from "@supabase/supabase-js"; @@ -30,6 +30,7 @@ type User = { interface CreateContextOptions { user: User | null; db: SupabaseClient; + adminDb: SupabaseClient; } /** @@ -47,6 +48,7 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => { return { user: opts.user, db: opts.db, + adminDb: opts.adminDb, }; }; @@ -61,12 +63,13 @@ export const createTRPCContext = async ({ resHeaders, }: FetchCreateContextFnOptions) => { const db = createTRPCClient(req, resHeaders); + const adminDb = createTRPCAdminClient(); const { data: { user }, } = await db.auth.getUser(); - return createInnerTRPCContext({ db, user }); + return createInnerTRPCContext({ db, adminDb, user }); }; /** diff --git a/src/server/db/migrations/0003_naive_secret_warriors.sql b/src/server/db/migrations/0003_naive_secret_warriors.sql new file mode 100644 index 00000000..e510124b --- /dev/null +++ b/src/server/db/migrations/0003_naive_secret_warriors.sql @@ -0,0 +1,22 @@ +DO $$ BEGIN + CREATE TYPE "member_status" AS ENUM('invited', 'active', 'removed'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk"; +--> statement-breakpoint +ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk"; +--> statement-breakpoint +ALTER TABLE "workspace_members" ADD COLUMN "status" "member_status" DEFAULT 'invited' NOT NULL;--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; diff --git a/src/server/db/migrations/meta/0003_snapshot.json b/src/server/db/migrations/meta/0003_snapshot.json new file mode 100644 index 00000000..ad09b83b --- /dev/null +++ b/src/server/db/migrations/meta/0003_snapshot.json @@ -0,0 +1,1018 @@ +{ + "version": "5", + "dialect": "pg", + "id": "4e3f672e-52ef-4b8c-8628-19f6db49d7c4", + "prevId": "3cce2217-3ea4-4132-bf7c-1c028901693c", + "tables": { + "board": { + "name": "board", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "importId": { + "name": "importId", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "workspaceId": { + "name": "workspaceId", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "board_createdBy_user_id_fk": { + "name": "board_createdBy_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "board_deletedBy_user_id_fk": { + "name": "board_deletedBy_user_id_fk", + "tableFrom": "board", + "tableTo": "user", + "columnsFrom": [ + "deletedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "board_importId_import_id_fk": { + "name": "board_importId_import_id_fk", + "tableFrom": "board", + "tableTo": "import", + "columnsFrom": [ + "importId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "board_workspaceId_workspace_id_fk": { + "name": "board_workspaceId_workspace_id_fk", + "tableFrom": "board", + "tableTo": "workspace", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "board_publicId_unique": { + "name": "board_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "_card_workspace_members": { + "name": "_card_workspace_members", + "schema": "", + "columns": { + "cardId": { + "name": "cardId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "workspaceMemberId": { + "name": "workspaceMemberId", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "_card_workspace_members_cardId_card_id_fk": { + "name": "_card_workspace_members_cardId_card_id_fk", + "tableFrom": "_card_workspace_members", + "tableTo": "card", + "columnsFrom": [ + "cardId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "_card_workspace_members_workspaceMemberId_workspace_members_id_fk": { + "name": "_card_workspace_members_workspaceMemberId_workspace_members_id_fk", + "tableFrom": "_card_workspace_members", + "tableTo": "workspace_members", + "columnsFrom": [ + "workspaceMemberId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "_card_workspace_members_cardId_workspaceMemberId": { + "name": "_card_workspace_members_cardId_workspaceMemberId", + "columns": [ + "cardId", + "workspaceMemberId" + ] + } + }, + "uniqueConstraints": {} + }, + "card": { + "name": "card", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "listId": { + "name": "listId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "importId": { + "name": "importId", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "card_createdBy_user_id_fk": { + "name": "card_createdBy_user_id_fk", + "tableFrom": "card", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "card_deletedBy_user_id_fk": { + "name": "card_deletedBy_user_id_fk", + "tableFrom": "card", + "tableTo": "user", + "columnsFrom": [ + "deletedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "card_listId_list_id_fk": { + "name": "card_listId_list_id_fk", + "tableFrom": "card", + "tableTo": "list", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "card_importId_import_id_fk": { + "name": "card_importId_import_id_fk", + "tableFrom": "card", + "tableTo": "import", + "columnsFrom": [ + "importId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "card_publicId_unique": { + "name": "card_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "_card_labels": { + "name": "_card_labels", + "schema": "", + "columns": { + "cardId": { + "name": "cardId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "labelId": { + "name": "labelId", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "_card_labels_cardId_card_id_fk": { + "name": "_card_labels_cardId_card_id_fk", + "tableFrom": "_card_labels", + "tableTo": "card", + "columnsFrom": [ + "cardId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "_card_labels_labelId_label_id_fk": { + "name": "_card_labels_labelId_label_id_fk", + "tableFrom": "_card_labels", + "tableTo": "label", + "columnsFrom": [ + "labelId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "_card_labels_cardId_labelId": { + "name": "_card_labels_cardId_labelId", + "columns": [ + "cardId", + "labelId" + ] + } + }, + "uniqueConstraints": {} + }, + "import": { + "name": "import", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "source", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "status", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "import_createdBy_user_id_fk": { + "name": "import_createdBy_user_id_fk", + "tableFrom": "import", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "import_publicId_unique": { + "name": "import_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "label": { + "name": "label", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "colourCode": { + "name": "colourCode", + "type": "varchar(12)", + "primaryKey": false, + "notNull": false + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boardId": { + "name": "boardId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "importId": { + "name": "importId", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "label_createdBy_user_id_fk": { + "name": "label_createdBy_user_id_fk", + "tableFrom": "label", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "label_boardId_board_id_fk": { + "name": "label_boardId_board_id_fk", + "tableFrom": "label", + "tableTo": "board", + "columnsFrom": [ + "boardId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "label_importId_import_id_fk": { + "name": "label_importId_import_id_fk", + "tableFrom": "label", + "tableTo": "import", + "columnsFrom": [ + "importId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "label_publicId_unique": { + "name": "label_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "list": { + "name": "list", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "index": { + "name": "index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "boardId": { + "name": "boardId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "importId": { + "name": "importId", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "list_createdBy_user_id_fk": { + "name": "list_createdBy_user_id_fk", + "tableFrom": "list", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "list_deletedBy_user_id_fk": { + "name": "list_deletedBy_user_id_fk", + "tableFrom": "list", + "tableTo": "user", + "columnsFrom": [ + "deletedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "list_boardId_board_id_fk": { + "name": "list_boardId_board_id_fk", + "tableFrom": "list", + "tableTo": "board", + "columnsFrom": [ + "boardId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "list_importId_import_id_fk": { + "name": "list_importId_import_id_fk", + "tableFrom": "list", + "tableTo": "import", + "columnsFrom": [ + "importId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "list_publicId_unique": { + "name": "list_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + } + }, + "workspace_members": { + "name": "workspace_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workspaceId": { + "name": "workspaceId", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "role", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "member_status", + "primaryKey": false, + "notNull": true, + "default": "'invited'" + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_members_userId_user_id_fk": { + "name": "workspace_members_userId_user_id_fk", + "tableFrom": "workspace_members", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_members_workspaceId_workspace_id_fk": { + "name": "workspace_members_workspaceId_workspace_id_fk", + "tableFrom": "workspace_members", + "tableTo": "workspace", + "columnsFrom": [ + "workspaceId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_members_publicId_unique": { + "name": "workspace_members_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + } + } + }, + "workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "publicId": { + "name": "publicId", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "createdBy": { + "name": "createdBy", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedAt": { + "name": "deletedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deletedBy": { + "name": "deletedBy", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "workspace_createdBy_user_id_fk": { + "name": "workspace_createdBy_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "createdBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_deletedBy_user_id_fk": { + "name": "workspace_deletedBy_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "deletedBy" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_publicId_unique": { + "name": "workspace_publicId_unique", + "nullsNotDistinct": false, + "columns": [ + "publicId" + ] + }, + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + } + } + }, + "enums": { + "source": { + "name": "source", + "values": { + "trello": "trello" + } + }, + "status": { + "name": "status", + "values": { + "started": "started", + "success": "success", + "failed": "failed" + } + }, + "role": { + "name": "role", + "values": { + "admin": "admin", + "member": "member", + "guest": "guest" + } + }, + "member_status": { + "name": "member_status", + "values": { + "invited": "invited", + "active": "active", + "removed": "removed" + } + } + }, + "schemas": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/src/server/db/migrations/meta/_journal.json b/src/server/db/migrations/meta/_journal.json index e1ddc2ef..5bcd3a2e 100644 --- a/src/server/db/migrations/meta/_journal.json +++ b/src/server/db/migrations/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1724967733894, "tag": "0002_clever_robin_chapel", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1728246215706, + "tag": "0003_naive_secret_warriors", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/src/server/db/repository/member.repo.ts b/src/server/db/repository/member.repo.ts new file mode 100644 index 00000000..a87c8684 --- /dev/null +++ b/src/server/db/repository/member.repo.ts @@ -0,0 +1,30 @@ +import { generateUID } from "~/utils/generateUID"; +import { type Database } from "~/types/database.types"; +import { type SupabaseClient } from "@supabase/supabase-js"; + +export const create = async ( + db: SupabaseClient, + memberInput: { + userId: string; + workspaceId: number; + createdBy: string; + role: "admin" | "member" | "guest"; + status: "invited" | "active" | "removed"; + }, +) => { + const { data } = await db + .from("workspace_members") + .insert({ + publicId: generateUID(), + userId: memberInput.userId, + workspaceId: memberInput.workspaceId, + createdBy: memberInput.createdBy, + role: memberInput.role, + status: memberInput.status, + }) + .select(`id, publicId`) + .limit(1) + .single(); + + return data; +}; diff --git a/src/server/db/repository/user.repo.ts b/src/server/db/repository/user.repo.ts index ccaa4b19..360e0371 100644 --- a/src/server/db/repository/user.repo.ts +++ b/src/server/db/repository/user.repo.ts @@ -11,3 +11,31 @@ export const getById = async (db: SupabaseClient, userId: string) => { return data; }; + +export const getByEmail = async ( + db: SupabaseClient, + email: string, +) => { + const { data } = await db + .from("user") + .select(`id, name, email`) + .eq("email", email) + .limit(1) + .single(); + + return data; +}; + +export const create = async ( + db: SupabaseClient, + user: { id: string; email: string }, +) => { + const { data } = await db + .from("user") + .insert({ id: user.id, email: user.email }) + .select() + .limit(1) + .single(); + + return data; +}; diff --git a/src/server/db/repository/workspace.repo.ts b/src/server/db/repository/workspace.repo.ts index f99dff7f..924f9b14 100644 --- a/src/server/db/repository/workspace.repo.ts +++ b/src/server/db/repository/workspace.repo.ts @@ -75,6 +75,7 @@ export const getByPublicIdWithMembers = async ( .from("workspace") .select( ` + id, publicId, members: workspace_members ( publicId, @@ -89,6 +90,7 @@ export const getByPublicIdWithMembers = async ( ) .eq("publicId", workspacePublicId) .is("deletedAt", null) + .is("members.deletedAt", null) .limit(1) .single(); @@ -99,7 +101,7 @@ export const getAllByUserId = async ( db: SupabaseClient, userId: string, ) => { - const { data } = await db + const { data, error } = await db .from("workspace_members") .select( ` @@ -113,6 +115,8 @@ export const getAllByUserId = async ( .eq("userId", userId) .is("deletedAt", null); + console.log({ error }); + return data; }; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index be2396a0..6331cec6 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -19,6 +19,11 @@ export const importStatusEnum = pgEnum("status", [ "failed", ]); export const memberRoleEnum = pgEnum("role", ["admin", "member", "guest"]); +export const memberStatusEnum = pgEnum("member_status", [ + "invited", + "active", + "removed", +]); export const boards = pgTable("board", { id: bigserial("id", { mode: "number" }).primaryKey(), @@ -294,6 +299,7 @@ export const workspaceMembers = pgTable("workspace_members", { updatedAt: timestamp("updatedAt"), deletedAt: timestamp("deletedAt"), role: memberRoleEnum("role").notNull(), + status: memberStatusEnum("status").default("invited").notNull(), }); export const usersToWorkspacesRelations = relations( diff --git a/src/types/database.types.ts b/src/types/database.types.ts index c1614345..8e3532a3 100644 --- a/src/types/database.types.ts +++ b/src/types/database.types.ts @@ -458,6 +458,7 @@ export type Database = { id: number publicId: string role: Database["public"]["Enums"]["role"] + status: Database["public"]["Enums"]["member_status"] updatedAt: string | null userId: string workspaceId: number @@ -469,6 +470,7 @@ export type Database = { id?: number publicId: string role: Database["public"]["Enums"]["role"] + status?: Database["public"]["Enums"]["member_status"] updatedAt?: string | null userId: string workspaceId: number @@ -480,6 +482,7 @@ export type Database = { id?: number publicId?: string role?: Database["public"]["Enums"]["role"] + status?: Database["public"]["Enums"]["member_status"] updatedAt?: string | null userId?: string workspaceId?: number @@ -548,9 +551,11 @@ export type Database = { } } Enums: { + member_status: "invited" | "active" | "removed" role: "admin" | "member" | "guest" source: "trello" status: "started" | "success" | "failed" + workspace_invite_status: "pending" | "accepted" | "cancelled" } CompositeTypes: { [_ in never]: never diff --git a/src/types/router.types.ts b/src/types/router.types.ts index 3366de8f..9762d694 100644 --- a/src/types/router.types.ts +++ b/src/types/router.types.ts @@ -8,3 +8,4 @@ export type NewLabelInput = RouterInputs["label"]["create"]; export type NewListInput = RouterInputs["list"]["create"]; export type NewCardInput = RouterInputs["card"]["create"]; export type NewBoardInput = RouterInputs["board"]["create"]; +export type InviteMemberInput = RouterInputs["member"]["invite"]; diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index f849c4cc..19d11c15 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -6,3 +6,25 @@ export const formatToArray = ( } return value ? [value] : []; }; + +export const inferInitialsFromEmail = (email: string) => { + const localPart = email.split("@")[0]; + if (!localPart) return ""; + const separators = /[._-]/; + const parts = localPart.split(separators); + + if (parts.length > 1) { + return ( + (parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? "") + ).toUpperCase(); + } else { + return localPart.slice(0, 2).toUpperCase(); + } +}; + +export const getInitialsFromName = (name: string) => { + return name + .split(" ") + .map((namePart) => namePart.charAt(0).toUpperCase()) + .join(""); +}; diff --git a/src/utils/supabase/api.ts b/src/utils/supabase/api.ts index 89f60fc3..8c45a0d4 100644 --- a/src/utils/supabase/api.ts +++ b/src/utils/supabase/api.ts @@ -56,3 +56,13 @@ export function createTRPCClient(req: Request, resHeaders: Headers) { return supabase; } + +export function createTRPCAdminClient() { + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.SUPABASE_SERVICE_API_KEY!, + { cookies: {} }, + ); + + return supabase; +} diff --git a/src/views/members/components/InviteMemberForm.tsx b/src/views/members/components/InviteMemberForm.tsx new file mode 100644 index 00000000..7cac3c5f --- /dev/null +++ b/src/views/members/components/InviteMemberForm.tsx @@ -0,0 +1,69 @@ +import { api } from "~/utils/api"; +import { HiXMark } from "react-icons/hi2"; +import { useModal } from "~/providers/modal"; +import { useWorkspace } from "~/providers/workspace"; +import { useForm } from "react-hook-form"; +import { type InviteMemberInput } from "~/types/router.types"; + +export function InviteMemberForm() { + const utils = api.useUtils(); + const { closeModal } = useModal(); + const { workspace } = useWorkspace(); + + const { register, handleSubmit } = useForm({ + defaultValues: { + email: "", + workspacePublicId: workspace?.publicId || "", + }, + }); + + const refetchBoards = () => utils.board.all.refetch(); + + const createBoard = api.member.invite.useMutation({ + onSuccess: async () => { + closeModal(); + await utils.workspace.byId.refetch(); + await refetchBoards(); + }, + }); + + const onSubmit = (data: InviteMemberInput) => { + createBoard.mutate(data); + }; + + return ( +
+
+
+

Add member

+ +
+ +
+ +
+
+ +
+
+
+ ); +} diff --git a/src/views/members/index.tsx b/src/views/members/index.tsx index 5e5dc896..0f92b0c7 100644 --- a/src/views/members/index.tsx +++ b/src/views/members/index.tsx @@ -4,11 +4,12 @@ import { useWorkspace } from "~/providers/workspace"; import Modal from "~/components/modal"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; - +import { InviteMemberForm } from "./components/InviteMemberForm"; import { api } from "~/utils/api"; +import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers"; export default function MembersPage() { - const { modalContentType } = useModal(); + const { modalContentType, openModal } = useModal(); const { workspace } = useWorkspace(); const { data } = api.workspace.byId.useQuery( @@ -26,7 +27,7 @@ export default function MembersPage() {