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
This commit is contained in:
2026-05-25 15:57:33 -04:00
parent 71972436b6
commit 28c33a930d
13 changed files with 1124 additions and 5 deletions

View File

@@ -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;

View File

@@ -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<Ctx>().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<string, unknown>);
}),
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> = {}): 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");
});
});

View File

@@ -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;
}),
});

View File

@@ -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"]),
});

View File

@@ -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<string, unknown>)[name], `Missing table: ${name}`).toBeDefined();
}
});
it("exports all 28 enums", () => {
it("exports all 29 enums", () => {
for (const name of enumNames) {
expect((schema as Record<string, unknown>)[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<string, unknown>)[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", () => {

View File

@@ -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) => ({

View File

@@ -11,4 +11,5 @@ export * from "./reports";
export * from "./marketing";
export * from "./hometitle";
export * from "./removebrokers";
export * from "./invitation";
export * from "./relations";

View File

@@ -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()),
});

View File

@@ -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 }) => ({

View File

@@ -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<void>) => {
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" });
});
});

View File

@@ -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));
});
}

View File

@@ -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);
});
});

View File

@@ -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;
}