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:
@@ -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;
|
||||
|
||||
240
web/src/server/api/routers/user.test.ts
Normal file
240
web/src/server/api/routers/user.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
87
web/src/server/api/routers/user.ts
Normal file
87
web/src/server/api/routers/user.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
21
web/src/server/api/schemas/user.ts
Normal file
21
web/src/server/api/schemas/user.ts
Normal 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"]),
|
||||
});
|
||||
Reference in New Issue
Block a user