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"]),
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from "./reports";
|
||||
export * from "./marketing";
|
||||
export * from "./hometitle";
|
||||
export * from "./removebrokers";
|
||||
export * from "./invitation";
|
||||
export * from "./relations";
|
||||
|
||||
18
web/src/server/db/schema/invitation.ts
Normal file
18
web/src/server/db/schema/invitation.ts
Normal 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()),
|
||||
});
|
||||
@@ -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 }) => ({
|
||||
|
||||
266
web/src/server/services/family.service.test.ts
Normal file
266
web/src/server/services/family.service.test.ts
Normal 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" });
|
||||
});
|
||||
});
|
||||
249
web/src/server/services/family.service.ts
Normal file
249
web/src/server/services/family.service.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
140
web/src/server/services/user.service.test.ts
Normal file
140
web/src/server/services/user.service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
82
web/src/server/services/user.service.ts
Normal file
82
web/src/server/services/user.service.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user