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