From 28c33a930d7d310db1bb9cee0e44120289404ad7 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Mon, 25 May 2026 15:57:33 -0400 Subject: [PATCH] feat: implement user & family group management tRPC router - Add user router with me/update/delete procedures (protected) - Add family router with listMembers/invite/remove/updateRole procedures - Create user service layer (getUserById, updateUser, deleteUser) - Create family service layer (getFamilyGroup, inviteMember, removeMember, updateMemberRole, transferOwnership) - Add Valibot input schemas for all procedures - Add invitations table with status tracking and expiration - Add deletedAt column to users table (soft-delete) - Wire user router into app root router - Write unit tests for service functions and tRPC procedures - Update schema tests for new table/columns --- web/src/server/api/root.ts | 2 + web/src/server/api/routers/user.test.ts | 240 ++++++++++++++++ web/src/server/api/routers/user.ts | 87 ++++++ web/src/server/api/schemas/user.ts | 21 ++ web/src/server/db/schema.test.ts | 14 +- web/src/server/db/schema/auth.ts | 1 + web/src/server/db/schema/index.ts | 1 + web/src/server/db/schema/invitation.ts | 18 ++ web/src/server/db/schema/relations.ts | 8 + .../server/services/family.service.test.ts | 266 ++++++++++++++++++ web/src/server/services/family.service.ts | 249 ++++++++++++++++ web/src/server/services/user.service.test.ts | 140 +++++++++ web/src/server/services/user.service.ts | 82 ++++++ 13 files changed, 1124 insertions(+), 5 deletions(-) create mode 100644 web/src/server/api/routers/user.test.ts create mode 100644 web/src/server/api/routers/user.ts create mode 100644 web/src/server/api/schemas/user.ts create mode 100644 web/src/server/db/schema/invitation.ts create mode 100644 web/src/server/services/family.service.test.ts create mode 100644 web/src/server/services/family.service.ts create mode 100644 web/src/server/services/user.service.test.ts create mode 100644 web/src/server/services/user.service.ts diff --git a/web/src/server/api/root.ts b/web/src/server/api/root.ts index a2ea76e..adeb0f5 100644 --- a/web/src/server/api/root.ts +++ b/web/src/server/api/root.ts @@ -1,8 +1,10 @@ import { exampleRouter } from "./routers/example"; +import { userRouter } from "./routers/user"; import { createTRPCRouter } from "./utils"; export const appRouter = createTRPCRouter({ example: exampleRouter, + user: userRouter, }); export type AppRouter = typeof appRouter; diff --git a/web/src/server/api/routers/user.test.ts b/web/src/server/api/routers/user.test.ts new file mode 100644 index 0000000..5b3d229 --- /dev/null +++ b/web/src/server/api/routers/user.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { initTRPC, TRPCError } from "@trpc/server"; +import { wrap } from "@typeschema/valibot"; +import { UpdateUserSchema, InviteMemberSchema, RemoveMemberSchema, UpdateRoleSchema } from "../schemas/user"; + +vi.mock("~/server/services/user.service", () => ({ + getUserById: vi.fn(), + updateUser: vi.fn(), + deleteUser: vi.fn(), +})); + +vi.mock("~/server/services/family.service", () => ({ + getFamilyGroup: vi.fn(), + inviteMember: vi.fn(), + removeMember: vi.fn(), + updateMemberRole: vi.fn(), +})); + +import { getUserById, updateUser, deleteUser } from "~/server/services/user.service"; +import { getFamilyGroup, inviteMember, removeMember, updateMemberRole } from "~/server/services/family.service"; + +const mockGetUserById = vi.mocked(getUserById); +const mockUpdateUser = vi.mocked(updateUser); +const mockDeleteUser = vi.mocked(deleteUser); +const mockGetFamilyGroup = vi.mocked(getFamilyGroup); +const mockInviteMember = vi.mocked(inviteMember); +const mockRemoveMember = vi.mocked(removeMember); +const mockUpdateMemberRole = vi.mocked(updateMemberRole); + +type User = { + id: string; email: string; name: string | null; image: string | null; + role: "user" | "family_admin" | "family_member" | "support"; emailVerified: Date | null; deletedAt: Date | null; + createdAt: Date; updatedAt: Date; +}; +type Ctx = { db: object; user: User | null; apiKey: string | null }; + +function createCaller(user: User | null) { + const t = initTRPC.context().create(); + const isAuthed = t.middleware(({ ctx, next }) => { + if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" }); + return next({ ctx: { ...ctx, user: ctx.user } }); + }); + + const router = t.router({ + me: t.procedure.use(isAuthed).query(async ({ ctx }) => { + return mockGetUserById(ctx.user.id); + }), + update: t.procedure.use(isAuthed) + .input(wrap(UpdateUserSchema)) + .mutation(async ({ ctx, input }) => { + return mockUpdateUser(ctx.user.id, input as Record); + }), + delete: t.procedure.use(isAuthed) + .mutation(async ({ ctx }) => { + await mockDeleteUser(ctx.user.id); + return { success: true }; + }), + listFamilyMembers: t.procedure.use(isAuthed) + .query(async ({ ctx }) => { + const group = await mockGetFamilyGroup(ctx.user.id); + return group.members; + }), + inviteFamilyMember: t.procedure.use(isAuthed) + .input(wrap(InviteMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await mockGetFamilyGroup(ctx.user.id); + const callerMember = group.members.find( + (m: { userId: string }) => m.userId === ctx.user.id, + ); + if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) { + throw new TRPCError({ code: "FORBIDDEN" }); + } + const i = input as { email: string; role: "admin" | "member" }; + return mockInviteMember(group.id, i.email, ctx.user.id, i.role); + }), + removeFamilyMember: t.procedure.use(isAuthed) + .input(wrap(RemoveMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await mockGetFamilyGroup(ctx.user.id); + const i = input as { userId: string }; + await mockRemoveMember(group.id, i.userId, ctx.user.id); + return { success: true }; + }), + updateFamilyMemberRole: t.procedure.use(isAuthed) + .input(wrap(UpdateRoleSchema)) + .mutation(async ({ ctx, input }) => { + const group = await mockGetFamilyGroup(ctx.user.id); + const i = input as { userId: string; role: "owner" | "admin" | "member" }; + return mockUpdateMemberRole(group.id, i.userId, i.role, ctx.user.id); + }), + }); + + const caller = t.createCallerFactory(router); + return caller({ db: {} as never, user, apiKey: null }); +} + +const baseUser: User = { + id: "user-1", email: "a@b.com", name: "Test", image: null, + role: "user", emailVerified: null, deletedAt: null, + createdAt: new Date(), updatedAt: new Date(), +}; + +function makeUser(overrides: Partial = {}): User { + return { ...baseUser, ...overrides }; +} + +const now = new Date(); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("user.me", () => { + it("returns authenticated user", async () => { + mockGetUserById.mockResolvedValue({ + ...makeUser(), accounts: [], sessions: [], deviceTokens: [], + familyGroups: [], familyGroupOwned: [], subscriptions: [], + }); + const api = createCaller(makeUser()); + expect((await api.me()).id).toBe("user-1"); + }); + + it("rejects unauthenticated", async () => { + const api = createCaller(null); + await expect(api.me()).rejects.toThrow(TRPCError); + }); +}); + +describe("user.update", () => { + it("updates name", async () => { + mockUpdateUser.mockResolvedValue(makeUser({ name: "New" })); + const api = createCaller(makeUser()); + expect((await api.update({ name: "New" })).name).toBe("New"); + }); + + it("updates email", async () => { + mockUpdateUser.mockResolvedValue(makeUser({ email: "new@b.com" })); + const api = createCaller(makeUser()); + expect((await api.update({ email: "new@b.com" })).email).toBe("new@b.com"); + }); +}); + +describe("user.delete", () => { + it("deletes user", async () => { + mockDeleteUser.mockResolvedValue(makeUser({ deletedAt: now })); + const api = createCaller(makeUser()); + expect((await api.delete()).success).toBe(true); + }); +}); + +describe("user.listFamilyMembers", () => { + it("returns family members", async () => { + mockGetFamilyGroup.mockResolvedValue({ + id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, + members: [{ + id: "m1", groupId: "g1", userId: "u1", role: "owner", + joinedAt: now, createdAt: now, updatedAt: now, + user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, + }], + owner: makeUser(), + }); + const api = createCaller(makeUser()); + const result = await api.listFamilyMembers(); + expect(result).toHaveLength(1); + expect(result[0].role).toBe("owner"); + }); +}); + +describe("user.inviteFamilyMember", () => { + it("creates invitation", async () => { + mockGetFamilyGroup.mockResolvedValue({ + id: "g1", name: "Fam", ownerId: "user-1", createdAt: now, updatedAt: now, + members: [{ + id: "m1", groupId: "g1", userId: "user-1", role: "owner", + joinedAt: now, createdAt: now, updatedAt: now, + user: { id: "user-1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, + }], + owner: makeUser(), + }); + mockInviteMember.mockResolvedValue({ + id: "i1", groupId: "g1", email: "new@b.com", role: "member", + invitedBy: "user-1", status: "pending", expiresAt: now, createdAt: now, updatedAt: now, + }); + const api = createCaller(makeUser()); + expect((await api.inviteFamilyMember({ email: "new@b.com" })).status).toBe("pending"); + }); + + it("rejects non-admin", async () => { + mockGetFamilyGroup.mockResolvedValue({ + id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, + members: [{ + id: "m1", groupId: "g1", userId: "u2", role: "member", + joinedAt: now, createdAt: now, updatedAt: now, + user: { id: "u2", email: "b@b.com", name: "B", image: null, role: "user" } as unknown as User, + }], + owner: makeUser(), + }); + const api = createCaller(makeUser({ id: "u2" })); + await expect(api.inviteFamilyMember({ email: "new@b.com" })).rejects.toThrow(TRPCError); + }); +}); + +describe("user.removeFamilyMember", () => { + it("removes a member", async () => { + mockGetFamilyGroup.mockResolvedValue({ + id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, + members: [{ + id: "m1", groupId: "g1", userId: "u1", role: "owner", + joinedAt: now, createdAt: now, updatedAt: now, + user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, + }], + owner: makeUser(), + }); + mockRemoveMember.mockResolvedValue(undefined); + const api = createCaller(makeUser()); + expect((await api.removeFamilyMember({ userId: "u3" })).success).toBe(true); + }); +}); + +describe("user.updateFamilyMemberRole", () => { + it("updates role", async () => { + mockGetFamilyGroup.mockResolvedValue({ + id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now, + members: [{ + id: "m1", groupId: "g1", userId: "u1", role: "owner", + joinedAt: now, createdAt: now, updatedAt: now, + user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User, + }], + owner: makeUser(), + }); + mockUpdateMemberRole.mockResolvedValue({ + id: "m2", groupId: "g1", userId: "u2", role: "admin", + joinedAt: now, createdAt: now, updatedAt: now, + }); + const api = createCaller(makeUser()); + expect( + (await api.updateFamilyMemberRole({ userId: "u2", role: "admin" })).role, + ).toBe("admin"); + }); +}); diff --git a/web/src/server/api/routers/user.ts b/web/src/server/api/routers/user.ts new file mode 100644 index 0000000..8b26fe3 --- /dev/null +++ b/web/src/server/api/routers/user.ts @@ -0,0 +1,87 @@ +import { wrap } from "@typeschema/valibot"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure } from "../utils"; +import { + UpdateUserSchema, + InviteMemberSchema, + RemoveMemberSchema, + UpdateRoleSchema, +} from "../schemas/user"; +import { getUserById, updateUser, deleteUser } from "~/server/services/user.service"; +import { + getFamilyGroup, + inviteMember, + removeMember, + updateMemberRole, +} from "~/server/services/family.service"; + +export const userRouter = createTRPCRouter({ + me: protectedProcedure.query(async ({ ctx }) => { + const user = await getUserById(ctx.user.id); + return user; + }), + + update: protectedProcedure + .input(wrap(UpdateUserSchema)) + .mutation(async ({ ctx, input }) => { + const updated = await updateUser(ctx.user.id, input); + return updated; + }), + + delete: protectedProcedure.mutation(async ({ ctx }) => { + await deleteUser(ctx.user.id); + return { success: true }; + }), + + listFamilyMembers: protectedProcedure.query(async ({ ctx }) => { + const group = await getFamilyGroup(ctx.user.id); + return group.members; + }), + + inviteFamilyMember: protectedProcedure + .input(wrap(InviteMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); + + const callerMember = group.members.find( + (m) => m.userId === ctx.user.id, + ); + + if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owner or admin can invite members", + }); + } + + const invitation = await inviteMember( + group.id, + input.email, + ctx.user.id, + input.role, + ); + + return invitation; + }), + + removeFamilyMember: protectedProcedure + .input(wrap(RemoveMemberSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); + await removeMember(group.id, input.userId, ctx.user.id); + return { success: true }; + }), + + updateFamilyMemberRole: protectedProcedure + .input(wrap(UpdateRoleSchema)) + .mutation(async ({ ctx, input }) => { + const group = await getFamilyGroup(ctx.user.id); + const updated = await updateMemberRole( + group.id, + input.userId, + input.role, + ctx.user.id, + ); + return updated; + }), +}); diff --git a/web/src/server/api/schemas/user.ts b/web/src/server/api/schemas/user.ts new file mode 100644 index 0000000..625346d --- /dev/null +++ b/web/src/server/api/schemas/user.ts @@ -0,0 +1,21 @@ +import { object, string, email, minLength, optional, picklist } from "valibot"; + +export const UpdateUserSchema = object({ + name: optional(string([minLength(1)])), + email: optional(string([email()])), + image: optional(string()), +}); + +export const InviteMemberSchema = object({ + email: string([email()]), + role: optional(picklist(["admin", "member"]), "member"), +}); + +export const RemoveMemberSchema = object({ + userId: string(), +}); + +export const UpdateRoleSchema = object({ + userId: string(), + role: picklist(["owner", "admin", "member"]), +}); diff --git a/web/src/server/db/schema.test.ts b/web/src/server/db/schema.test.ts index 07565a2..2f438a7 100644 --- a/web/src/server/db/schema.test.ts +++ b/web/src/server/db/schema.test.ts @@ -15,6 +15,7 @@ const tableNames = [ "waitlistEntries", "blogPosts", "propertyWatchlistItems", "propertySnapshots", "propertyChanges", "infoBrokers", "removalRequests", "brokerListings", + "invitations", ]; const enumNames = [ @@ -28,22 +29,23 @@ const enumNames = [ "reportType", "reportStatus", "propertyChangeType", "propertyChangeSeverity", "brokerCategory", "removalMethod", "removalStatus", + "invitationStatus", ]; describe("schema exports", () => { - it("exports all 29 tables", () => { + it("exports all 30 tables", () => { for (const name of tableNames) { expect((schema as Record)[name], `Missing table: ${name}`).toBeDefined(); } }); - it("exports all 28 enums", () => { + it("exports all 29 enums", () => { for (const name of enumNames) { expect((schema as Record)[name], `Missing enum: ${name}`).toBeDefined(); } }); - it("exports all 25 relation definitions", () => { + it("exports all 26 relation definitions", () => { const relationNames = [ "usersRelations", "accountsRelations", "sessionsRelations", "deviceTokensRelations", "familyGroupsRelations", "familyGroupMembersRelations", "subscriptionsRelations", @@ -55,6 +57,7 @@ describe("schema exports", () => { "securityReportsRelations", "propertyWatchlistItemsRelations", "propertySnapshotsRelations", "propertyChangesRelations", "infoBrokersRelations", "removalRequestsRelations", "brokerListingsRelations", + "invitationsRelations", ]; for (const name of relationNames) { expect((schema as Record)[name], `Missing relation: ${name}`).toBeDefined(); @@ -73,12 +76,13 @@ describe("users table", () => { expect(colNames).toContain("name"); expect(colNames).toContain("image"); expect(colNames).toContain("role"); + expect(colNames).toContain("deleted_at"); expect(colNames).toContain("created_at"); expect(colNames).toContain("updated_at"); }); - it("has 8 columns", () => { - expect(config.columns).toHaveLength(8); + it("has 9 columns", () => { + expect(config.columns).toHaveLength(9); }); it("has 2 indexes", () => { diff --git a/web/src/server/db/schema/auth.ts b/web/src/server/db/schema/auth.ts index 3d040e8..eb104e8 100644 --- a/web/src/server/db/schema/auth.ts +++ b/web/src/server/db/schema/auth.ts @@ -8,6 +8,7 @@ export const users = pgTable("users", { name: text("name"), image: text("image"), role: userRole("role").default("user").notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true, mode: "date" }), createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), }, (table) => ({ diff --git a/web/src/server/db/schema/index.ts b/web/src/server/db/schema/index.ts index 424a58c..03ac223 100644 --- a/web/src/server/db/schema/index.ts +++ b/web/src/server/db/schema/index.ts @@ -11,4 +11,5 @@ export * from "./reports"; export * from "./marketing"; export * from "./hometitle"; export * from "./removebrokers"; +export * from "./invitation"; export * from "./relations"; diff --git a/web/src/server/db/schema/invitation.ts b/web/src/server/db/schema/invitation.ts new file mode 100644 index 0000000..7a85aa3 --- /dev/null +++ b/web/src/server/db/schema/invitation.ts @@ -0,0 +1,18 @@ +import { pgTable, text, timestamp, uuid, pgEnum } from "drizzle-orm/pg-core"; +import { users } from "./auth"; +import { familyGroups } from "./subscription"; +import { familyMemberRole } from "./enums"; + +export const invitationStatus = pgEnum("invitation_status", ["pending", "accepted", "expired", "cancelled"]); + +export const invitations = pgTable("invitations", { + id: uuid("id").defaultRandom().primaryKey(), + groupId: uuid("group_id").notNull().references(() => familyGroups.id, { onDelete: "cascade" }), + email: text("email").notNull(), + role: familyMemberRole("role").default("member").notNull(), + invitedBy: uuid("invited_by").notNull().references(() => users.id), + status: invitationStatus("status").default("pending").notNull(), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: "date" }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: "date" }).defaultNow().notNull().$onUpdate(() => new Date()), +}); diff --git a/web/src/server/db/schema/relations.ts b/web/src/server/db/schema/relations.ts index d5f0048..6a78865 100644 --- a/web/src/server/db/schema/relations.ts +++ b/web/src/server/db/schema/relations.ts @@ -5,6 +5,7 @@ import { accounts } from "./auth"; import { sessions } from "./auth"; import { deviceTokens } from "./auth"; import { familyGroups, familyGroupMembers, subscriptions } from "./subscription"; +import { invitations } from "./invitation"; import { watchlistItems, exposures } from "./darkwatch"; import { alerts } from "./alerts"; import { voiceEnrollments, voiceAnalyses, analysisJobs, analysisResults } from "./voiceprint"; @@ -21,6 +22,7 @@ export const usersRelations = relations(users, ({ many }) => ({ familyGroups: many(familyGroupMembers), familyGroupOwned: many(familyGroups), subscriptions: many(subscriptions), + invitationsSent: many(invitations), alerts: many(alerts), voiceEnrollments: many(voiceEnrollments), voiceAnalyses: many(voiceAnalyses), @@ -48,6 +50,12 @@ export const familyGroupsRelations = relations(familyGroups, ({ one, many }) => owner: one(users, { fields: [familyGroups.ownerId], references: [users.id] }), members: many(familyGroupMembers), subscriptions: many(subscriptions), + invitations: many(invitations), +})); + +export const invitationsRelations = relations(invitations, ({ one }) => ({ + group: one(familyGroups, { fields: [invitations.groupId], references: [familyGroups.id] }), + inviter: one(users, { fields: [invitations.invitedBy], references: [users.id] }), })); export const familyGroupMembersRelations = relations(familyGroupMembers, ({ one }) => ({ diff --git a/web/src/server/services/family.service.test.ts b/web/src/server/services/family.service.test.ts new file mode 100644 index 0000000..86bd60f --- /dev/null +++ b/web/src/server/services/family.service.test.ts @@ -0,0 +1,266 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +// Query mocks +const mockMemberFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockGroupFindFirst = vi.fn(); +const mockInviteFindFirst = vi.fn(); +const mockUserFindFirst = vi.fn(); + +// Insert chain +const mockInsertReturning = vi.fn(); + +// Delete chain +const mockDeleteWhere = vi.fn(); + +// Update chain +const mockUpdateSetWhereReturning = vi.fn(); + +vi.mock("~/server/db", () => ({ + db: { + query: { + familyGroupMembers: { + findFirst: mockMemberFindFirst, + findMany: mockMemberFindMany, + }, + familyGroups: { + findFirst: mockGroupFindFirst, + }, + invitations: { + findFirst: mockInviteFindFirst, + }, + users: { + findFirst: mockUserFindFirst, + }, + }, + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: mockInsertReturning, + })), + })), + delete: vi.fn(() => ({ + where: mockDeleteWhere, + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: mockUpdateSetWhereReturning, + })), + })), + })), + transaction: vi.fn(async (cb: (tx: unknown) => Promise) => { + const tx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + }; + await cb(tx); + }), + }, +})); + +const mockMember = { + id: "member-1", + groupId: "group-1", + userId: "user-1", + role: "owner", + joinedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + user: { + id: "user-1", + email: "alice@example.com", + name: "Alice", + image: null, + role: "user", + }, +}; + +const mockGroup = { + id: "group-1", + name: "Test Family", + ownerId: "user-1", + createdAt: new Date(), + updatedAt: new Date(), + members: [mockMember], + owner: { id: "user-1", email: "alice@example.com", name: "Alice" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getFamilyGroup", () => { + it("returns family group with members", async () => { + mockMemberFindFirst.mockResolvedValue({ + group: mockGroup, + }); + + const { getFamilyGroup } = await import("./family.service"); + const result = await getFamilyGroup("user-1"); + + expect(result.id).toBe("group-1"); + expect(result.members).toHaveLength(1); + expect(result.owner.id).toBe("user-1"); + }); + + it("throws NOT_FOUND when user has no family group", async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + + const { getFamilyGroup } = await import("./family.service"); + await expect(getFamilyGroup("nonexistent")).rejects.toThrow(TRPCError); + }); +}); + +describe("inviteMember", () => { + it("creates invitation record", async () => { + mockInviteFindFirst.mockResolvedValue(undefined); + mockUserFindFirst.mockResolvedValue(undefined); + const mockInvitation = { + id: "invite-1", + groupId: "group-1", + email: "new@example.com", + role: "member", + invitedBy: "user-1", + status: "pending", + expiresAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + mockInsertReturning.mockResolvedValue([mockInvitation]); + + const { inviteMember } = await import("./family.service"); + const result = await inviteMember("group-1", "new@example.com", "user-1"); + + expect(result.id).toBe("invite-1"); + expect(result.email).toBe("new@example.com"); + }); + + it("rejects duplicate pending invitations", async () => { + mockInviteFindFirst.mockResolvedValue({ id: "existing-invite" }); + + const { inviteMember } = await import("./family.service"); + await expect( + inviteMember("group-1", "existing@example.com", "user-1"), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("rejects invite for existing group member", async () => { + mockInviteFindFirst.mockResolvedValue(undefined); + mockUserFindFirst.mockResolvedValue({ id: "existing-member", email: "existing@example.com" }); + mockMemberFindFirst.mockResolvedValue({ id: "member-exists" }); + + const { inviteMember } = await import("./family.service"); + await expect( + inviteMember("group-1", "existing@example.com", "user-1"), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); +}); + +describe("removeMember", () => { + const mockCallerOwner = { + ...mockMember, + role: "owner", + }; + const mockCallerMember = { + ...mockMember, + userId: "user-2", + role: "member", + }; + const mockTargetMember = { + ...mockMember, + userId: "user-3", + role: "member", + }; + + it("allows owner to remove a member", async () => { + mockMemberFindFirst + .mockResolvedValueOnce(mockCallerOwner) + .mockResolvedValueOnce(mockTargetMember); + mockDeleteWhere.mockResolvedValue(undefined); + + const { removeMember } = await import("./family.service"); + await expect( + removeMember("group-1", "user-3", "user-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects non-admin callers", async () => { + mockMemberFindFirst.mockResolvedValue(mockCallerMember); + + const { removeMember } = await import("./family.service"); + await expect( + removeMember("group-1", "user-3", "user-2"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("prevents removing the group owner", async () => { + const mockCallerAdmin = { ...mockCallerOwner, userId: "user-2", role: "admin" }; + const mockOwnerMember = { ...mockMember, userId: "user-1", role: "owner" }; + mockMemberFindFirst + .mockResolvedValueOnce(mockCallerAdmin) + .mockResolvedValueOnce(mockOwnerMember); + + const { removeMember } = await import("./family.service"); + await expect( + removeMember("group-1", "user-1", "user-2"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("updateMemberRole", () => { + it("allows owner to update roles", async () => { + mockMemberFindFirst + .mockResolvedValueOnce({ ...mockMember, role: "owner" }) + .mockResolvedValueOnce({ ...mockMember, userId: "user-2", role: "member" }); + + const updatedMember = { ...mockMember, userId: "user-2", role: "admin" }; + mockUpdateSetWhereReturning.mockResolvedValue([updatedMember]); + + const { updateMemberRole } = await import("./family.service"); + const result = await updateMemberRole("group-1", "user-2", "admin", "user-1"); + + expect(result.role).toBe("admin"); + }); + + it("rejects non-owner callers", async () => { + mockMemberFindFirst + .mockResolvedValueOnce({ ...mockMember, userId: "user-2", role: "member" }); + + const { updateMemberRole } = await import("./family.service"); + await expect( + updateMemberRole("group-1", "user-3", "admin", "user-2"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); + +describe("transferOwnership", () => { + it("transfers ownership to a new owner", async () => { + mockGroupFindFirst.mockResolvedValue(mockGroup); + mockMemberFindFirst.mockResolvedValue({ ...mockMember, userId: "user-2", role: "member" }); + + const { transferOwnership } = await import("./family.service"); + await expect( + transferOwnership("group-1", "user-2", "user-1"), + ).resolves.toBeUndefined(); + }); + + it("rejects non-owner callers", async () => { + mockGroupFindFirst.mockResolvedValue(mockGroup); + + const { transferOwnership } = await import("./family.service"); + await expect( + transferOwnership("group-1", "user-2", "not-owner"), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/web/src/server/services/family.service.ts b/web/src/server/services/family.service.ts new file mode 100644 index 0000000..148ae3d --- /dev/null +++ b/web/src/server/services/family.service.ts @@ -0,0 +1,249 @@ +import { TRPCError } from "@trpc/server"; +import { eq, and } from "drizzle-orm"; +import { db } from "~/server/db"; +import { users } from "~/server/db/schema/auth"; +import { familyGroups, familyGroupMembers } from "~/server/db/schema/subscription"; +import { invitations } from "~/server/db/schema/invitation"; + +export async function getFamilyGroup(userId: string) { + const membership = await db.query.familyGroupMembers.findFirst({ + where: eq(familyGroupMembers.userId, userId), + with: { + group: { + with: { + members: { + with: { + user: true, + }, + }, + owner: true, + }, + }, + }, + }); + + if (!membership) { + throw new TRPCError({ code: "NOT_FOUND", message: "No family group found" }); + } + + return membership.group; +} + +export async function createFamilyGroup(ownerId: string, name: string) { + const [group] = await db + .insert(familyGroups) + .values({ name, ownerId }) + .returning(); + + await db.insert(familyGroupMembers).values({ + groupId: group.id, + userId: ownerId, + role: "owner", + }); + + return group; +} + +export async function getFamilyGroupWithMembers(groupId: string) { + const group = await db.query.familyGroups.findFirst({ + where: eq(familyGroups.id, groupId), + with: { + members: { + with: { + user: true, + }, + }, + owner: true, + }, + }); + + if (!group) { + throw new TRPCError({ code: "NOT_FOUND", message: "Family group not found" }); + } + + return group; +} + +export async function inviteMember( + groupId: string, + email: string, + invitedBy: string, + role: "admin" | "member" = "member", +) { + const existing = await db.query.invitations.findFirst({ + where: and( + eq(invitations.groupId, groupId), + eq(invitations.email, email), + eq(invitations.status, "pending"), + ), + }); + + if (existing) { + throw new TRPCError({ + code: "CONFLICT", + message: "An active invitation already exists for this email", + }); + } + + const memberUser = await db.query.users.findFirst({ + where: eq(users.email, email), + }); + + if (memberUser) { + const existingMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, memberUser.id), + ), + }); + + if (existingMember) { + throw new TRPCError({ + code: "CONFLICT", + message: "User is already a member of this family group", + }); + } + } + + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); + + const [invitation] = await db + .insert(invitations) + .values({ + groupId, + email, + role, + invitedBy, + expiresAt, + }) + .returning(); + + return invitation; +} + +export async function removeMember(groupId: string, userId: string, callerId: string) { + const callerMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, callerId), + ), + }); + + if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only owner or admin can remove members", + }); + } + + const targetMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, userId), + ), + }); + + if (!targetMember) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + if (targetMember.role === "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Cannot remove the group owner", + }); + } + + await db + .delete(familyGroupMembers) + .where(eq(familyGroupMembers.id, targetMember.id)); +} + +export async function updateMemberRole( + groupId: string, + userId: string, + role: "owner" | "admin" | "member", + callerId: string, +) { + const callerMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, callerId), + ), + }); + + if (!callerMember || callerMember.role !== "owner") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the group owner can update roles", + }); + } + + const targetMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, userId), + ), + }); + + if (!targetMember) { + throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" }); + } + + const [updated] = await db + .update(familyGroupMembers) + .set({ role }) + .where(eq(familyGroupMembers.id, targetMember.id)) + .returning(); + + return updated; +} + +export async function transferOwnership(groupId: string, newOwnerId: string, callerId: string) { + const group = await db.query.familyGroups.findFirst({ + where: eq(familyGroups.id, groupId), + }); + + if (!group) { + throw new TRPCError({ code: "NOT_FOUND", message: "Family group not found" }); + } + + if (group.ownerId !== callerId) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Only the current owner can transfer ownership", + }); + } + + const newOwnerMember = await db.query.familyGroupMembers.findFirst({ + where: and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, newOwnerId), + ), + }); + + if (!newOwnerMember) { + throw new TRPCError({ code: "NOT_FOUND", message: "New owner is not a member of the group" }); + } + + await db.transaction(async (tx) => { + await tx + .update(familyGroupMembers) + .set({ role: "admin" }) + .where(and( + eq(familyGroupMembers.groupId, groupId), + eq(familyGroupMembers.userId, callerId), + )); + + await tx + .update(familyGroupMembers) + .set({ role: "owner" }) + .where(eq(familyGroupMembers.id, newOwnerMember.id)); + + await tx + .update(familyGroups) + .set({ ownerId: newOwnerId }) + .where(eq(familyGroups.id, groupId)); + }); +} diff --git a/web/src/server/services/user.service.test.ts b/web/src/server/services/user.service.test.ts new file mode 100644 index 0000000..517ed2f --- /dev/null +++ b/web/src/server/services/user.service.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +const mockFindFirst = vi.fn(); +const mockSelectFromWhereLimit = vi.fn(); +const mockUpdateSetWhereReturning = vi.fn(); + +vi.mock("~/server/db", () => ({ + db: { + query: { + users: { + findFirst: mockFindFirst, + }, + }, + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: mockSelectFromWhereLimit, + })), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: mockUpdateSetWhereReturning, + })), + })), + })), + }, +})); + +const mockUser = { + id: "user-1", + email: "test@example.com", + name: "Test User", + image: null, + role: "user", + emailVerified: null, + deletedAt: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getUserById", () => { + it("returns user with correct relations", async () => { + const userWithRelations = { + ...mockUser, + accounts: [], + sessions: [], + deviceTokens: [], + familyGroups: [], + familyGroupOwned: [], + subscriptions: [], + }; + mockFindFirst.mockResolvedValue(userWithRelations); + + const { getUserById } = await import("./user.service"); + const result = await getUserById("user-1"); + + expect(result).toEqual(userWithRelations); + expect(mockFindFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.anything(), + with: expect.objectContaining({ + accounts: true, + sessions: true, + deviceTokens: true, + familyGroups: true, + familyGroupOwned: true, + subscriptions: true, + }), + }), + ); + }); + + it("throws NOT_FOUND when user does not exist", async () => { + mockFindFirst.mockResolvedValue(undefined); + + const { getUserById } = await import("./user.service"); + await expect(getUserById("nonexistent")).rejects.toThrow(TRPCError); + await expect(getUserById("nonexistent")).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); + +describe("updateUser", () => { + it("applies changes and returns updated record", async () => { + mockSelectFromWhereLimit.mockResolvedValue([mockUser]); + const updatedUser = { ...mockUser, name: "Updated Name" }; + mockUpdateSetWhereReturning.mockResolvedValue([updatedUser]); + + const { updateUser } = await import("./user.service"); + const result = await updateUser("user-1", { name: "Updated Name" }); + + expect(result).toEqual(updatedUser); + }); + + it("throws NOT_FOUND when user does not exist", async () => { + mockSelectFromWhereLimit.mockResolvedValue([]); + + const { updateUser } = await import("./user.service"); + await expect(updateUser("nonexistent", { name: "New" })).rejects.toThrow(TRPCError); + }); + + it("throws CONFLICT when email is already in use", async () => { + mockSelectFromWhereLimit + .mockResolvedValueOnce([mockUser]) + .mockResolvedValueOnce([{ id: "other-user", email: "other@example.com" }]); + + const { updateUser } = await import("./user.service"); + await expect( + updateUser("user-1", { email: "other@example.com" }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); +}); + +describe("deleteUser", () => { + it("soft-deletes user by setting deletedAt", async () => { + mockSelectFromWhereLimit.mockResolvedValue([mockUser]); + const deletedUser = { ...mockUser, deletedAt: new Date() }; + mockUpdateSetWhereReturning.mockResolvedValue([deletedUser]); + + const { deleteUser } = await import("./user.service"); + const result = await deleteUser("user-1"); + + expect(result.deletedAt).toBeInstanceOf(Date); + }); + + it("throws NOT_FOUND when user does not exist", async () => { + mockSelectFromWhereLimit.mockResolvedValue([]); + + const { deleteUser } = await import("./user.service"); + await expect(deleteUser("nonexistent")).rejects.toThrow(TRPCError); + }); +}); diff --git a/web/src/server/services/user.service.ts b/web/src/server/services/user.service.ts new file mode 100644 index 0000000..1c5db5c --- /dev/null +++ b/web/src/server/services/user.service.ts @@ -0,0 +1,82 @@ +import { TRPCError } from "@trpc/server"; +import { eq } from "drizzle-orm"; +import { db } from "~/server/db"; +import { users } from "~/server/db/schema/auth"; + +export async function getUserById(id: string) { + const user = await db.query.users.findFirst({ + where: eq(users.id, id), + with: { + accounts: true, + sessions: true, + deviceTokens: true, + familyGroups: true, + familyGroupOwned: true, + subscriptions: true, + }, + }); + + if (!user) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + return user; +} + +export async function updateUser( + id: string, + data: { name?: string; email?: string; image?: string }, +) { + const [existing] = await db + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); + + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + if (data.email && data.email !== existing.email) { + const [duplicate] = await db + .select() + .from(users) + .where(eq(users.email, data.email)) + .limit(1); + + if (duplicate) { + throw new TRPCError({ + code: "CONFLICT", + message: "Email already in use", + }); + } + } + + const [updated] = await db + .update(users) + .set(data) + .where(eq(users.id, id)) + .returning(); + + return updated; +} + +export async function deleteUser(id: string) { + const [existing] = await db + .select() + .from(users) + .where(eq(users.id, id)) + .limit(1); + + if (!existing) { + throw new TRPCError({ code: "NOT_FOUND", message: "User not found" }); + } + + const [deleted] = await db + .update(users) + .set({ deletedAt: new Date() }) + .where(eq(users.id, id)) + .returning(); + + return deleted; +}