789 lines
27 KiB
TypeScript
789 lines
27 KiB
TypeScript
import { COOKIE_NAME } from "@shared/const";
|
|
import { getSessionCookieOptions } from "./_core/cookies";
|
|
import { systemRouter } from "./_core/systemRouter";
|
|
import { publicProcedure, protectedProcedure, router } from "./_core/trpc";
|
|
import { z } from "zod";
|
|
import { TRPCError } from "@trpc/server";
|
|
import * as db from "./db";
|
|
import { createBountyEscrowCheckout, createConnectAccount, createAccountLink, getAccountStatus, transferToConnectedAccount } from "./stripe";
|
|
|
|
export const appRouter = router({
|
|
system: systemRouter,
|
|
|
|
auth: router({
|
|
me: publicProcedure.query(opts => opts.ctx.user),
|
|
logout: publicProcedure.mutation(({ ctx }) => {
|
|
const cookieOptions = getSessionCookieOptions(ctx.req);
|
|
ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });
|
|
return { success: true } as const;
|
|
}),
|
|
}),
|
|
|
|
// ============ Category Routes ============
|
|
category: router({
|
|
list: publicProcedure.query(async () => {
|
|
return db.getAllCategories();
|
|
}),
|
|
|
|
getBySlug: publicProcedure
|
|
.input(z.object({ slug: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.getCategoryBySlug(input.slug);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
slug: z.string().min(1),
|
|
description: z.string().optional(),
|
|
icon: z.string().optional(),
|
|
parentId: z.number().optional(),
|
|
sortOrder: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以创建分类' });
|
|
}
|
|
await db.createCategory(input);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ Website Routes ============
|
|
website: router({
|
|
list: publicProcedure
|
|
.input(z.object({ categoryId: z.number().optional() }).optional())
|
|
.query(async ({ input }) => {
|
|
return db.getWebsites(input?.categoryId);
|
|
}),
|
|
|
|
get: publicProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getWebsiteById(input.id);
|
|
}),
|
|
|
|
search: publicProcedure
|
|
.input(z.object({ query: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.searchWebsites(input.query);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
url: z.string().url(),
|
|
logo: z.string().optional(),
|
|
description: z.string().optional(),
|
|
categoryId: z.number(),
|
|
rating: z.string().optional(),
|
|
isVerified: z.boolean().optional(),
|
|
sortOrder: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加网站' });
|
|
}
|
|
await db.createWebsite(input);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ Product Routes ============
|
|
product: router({
|
|
list: publicProcedure
|
|
.input(z.object({
|
|
categoryId: z.number().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
sortBy: z.enum(['price_asc', 'price_desc', 'newest', 'oldest']).optional()
|
|
}).optional())
|
|
.query(async ({ input }) => {
|
|
return db.getProducts(
|
|
input?.categoryId,
|
|
input?.limit || 50,
|
|
input?.offset || 0,
|
|
input?.sortBy
|
|
);
|
|
}),
|
|
|
|
get: publicProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getProductById(input.id);
|
|
}),
|
|
|
|
getWithPrices: publicProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getProductWithPrices(input.id);
|
|
}),
|
|
|
|
search: publicProcedure
|
|
.input(z.object({ query: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.searchProducts(input.query);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1),
|
|
description: z.string().optional(),
|
|
image: z.string().optional(),
|
|
categoryId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加商品' });
|
|
}
|
|
await db.createProduct(input);
|
|
return { success: true };
|
|
}),
|
|
|
|
addPrice: protectedProcedure
|
|
.input(z.object({
|
|
productId: z.number(),
|
|
websiteId: z.number(),
|
|
price: z.string(),
|
|
originalPrice: z.string().optional(),
|
|
currency: z.string().optional(),
|
|
url: z.string().url(),
|
|
inStock: z.boolean().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
if (ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有管理员可以添加价格' });
|
|
}
|
|
await db.createProductPrice(input);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ Bounty Routes ============
|
|
bounty: router({
|
|
list: publicProcedure
|
|
.input(z.object({
|
|
status: z.string().optional(),
|
|
limit: z.number().optional(),
|
|
offset: z.number().optional(),
|
|
}).optional())
|
|
.query(async ({ input }) => {
|
|
return db.getBounties(input?.status, input?.limit, input?.offset);
|
|
}),
|
|
|
|
get: publicProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.query(async ({ input }) => {
|
|
const bounty = await db.getBountyById(input.id);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
return bounty;
|
|
}),
|
|
|
|
search: publicProcedure
|
|
.input(z.object({ query: z.string() }))
|
|
.query(async ({ input }) => {
|
|
return db.searchBounties(input.query);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
title: z.string().min(1).max(300),
|
|
description: z.string().min(1),
|
|
reward: z.string(),
|
|
currency: z.string().optional(),
|
|
deadline: z.date().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bountyId = await db.createBounty({
|
|
...input,
|
|
publisherId: ctx.user.id,
|
|
});
|
|
return { success: true, bountyId };
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
title: z.string().min(1).max(300).optional(),
|
|
description: z.string().min(1).optional(),
|
|
reward: z.string().optional(),
|
|
deadline: z.date().optional(),
|
|
status: z.enum(['open', 'in_progress', 'completed', 'cancelled', 'disputed']).optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.id);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id && ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '无权修改此悬赏' });
|
|
}
|
|
const { id, ...updateData } = input;
|
|
await db.updateBounty(id, updateData);
|
|
return { success: true };
|
|
}),
|
|
|
|
cancel: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.id);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id && ctx.user.role !== 'admin') {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '无权取消此悬赏' });
|
|
}
|
|
if (bounty.bounty.status !== 'open') {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '只能取消开放中的悬赏' });
|
|
}
|
|
await db.updateBounty(input.id, { status: 'cancelled' });
|
|
return { success: true };
|
|
}),
|
|
|
|
complete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.id);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以确认完成' });
|
|
}
|
|
if (bounty.bounty.status !== 'in_progress') {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '只能完成进行中的悬赏' });
|
|
}
|
|
await db.updateBounty(input.id, {
|
|
status: 'completed',
|
|
completedAt: new Date()
|
|
});
|
|
|
|
// 创建通知给接单者
|
|
if (bounty.bounty.acceptorId) {
|
|
await db.createNotification({
|
|
userId: bounty.bounty.acceptorId,
|
|
type: 'bounty_completed',
|
|
title: '悬赏已完成',
|
|
content: `您接取的悬赏"${bounty.bounty.title}"已被确认完成`,
|
|
relatedId: input.id,
|
|
relatedType: 'bounty'
|
|
});
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
myPublished: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserBounties(ctx.user.id, 'published');
|
|
}),
|
|
|
|
myAccepted: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserBounties(ctx.user.id, 'accepted');
|
|
}),
|
|
}),
|
|
|
|
// ============ Bounty Application Routes ============
|
|
bountyApplication: router({
|
|
list: publicProcedure
|
|
.input(z.object({ bountyId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getBountyApplications(input.bountyId);
|
|
}),
|
|
|
|
submit: protectedProcedure
|
|
.input(z.object({
|
|
bountyId: z.number(),
|
|
message: z.string().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.bountyId);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.status !== 'open') {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '该悬赏不接受申请' });
|
|
}
|
|
if (bounty.bounty.publisherId === ctx.user.id) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '不能申请自己发布的悬赏' });
|
|
}
|
|
|
|
const existing = await db.getUserApplication(input.bountyId, ctx.user.id);
|
|
if (existing) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '您已经申请过此悬赏' });
|
|
}
|
|
|
|
await db.createBountyApplication({
|
|
bountyId: input.bountyId,
|
|
applicantId: ctx.user.id,
|
|
message: input.message,
|
|
});
|
|
|
|
// 通知发布者
|
|
await db.createNotification({
|
|
userId: bounty.bounty.publisherId,
|
|
type: 'bounty_accepted',
|
|
title: '新的悬赏申请',
|
|
content: `有人申请了您的悬赏"${bounty.bounty.title}"`,
|
|
relatedId: input.bountyId,
|
|
relatedType: 'bounty'
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
accept: protectedProcedure
|
|
.input(z.object({
|
|
applicationId: z.number(),
|
|
bountyId: z.number(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.bountyId);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以接受申请' });
|
|
}
|
|
|
|
const applications = await db.getBountyApplications(input.bountyId);
|
|
const application = applications.find(a => a.application.id === input.applicationId);
|
|
if (!application) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '申请不存在' });
|
|
}
|
|
|
|
await db.updateBountyApplication(input.applicationId, 'accepted');
|
|
await db.updateBounty(input.bountyId, {
|
|
status: 'in_progress',
|
|
acceptorId: application.application.applicantId,
|
|
});
|
|
|
|
// 拒绝其他申请
|
|
for (const app of applications) {
|
|
if (app.application.id !== input.applicationId) {
|
|
await db.updateBountyApplication(app.application.id, 'rejected');
|
|
}
|
|
}
|
|
|
|
// 通知接单者
|
|
await db.createNotification({
|
|
userId: application.application.applicantId,
|
|
type: 'bounty_accepted',
|
|
title: '申请已被接受',
|
|
content: `您对悬赏"${bounty.bounty.title}"的申请已被接受`,
|
|
relatedId: input.bountyId,
|
|
relatedType: 'bounty'
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
|
|
myApplication: protectedProcedure
|
|
.input(z.object({ bountyId: z.number() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return db.getUserApplication(input.bountyId, ctx.user.id);
|
|
}),
|
|
}),
|
|
|
|
// ============ Comment Routes ============
|
|
comment: router({
|
|
list: publicProcedure
|
|
.input(z.object({ bountyId: z.number() }))
|
|
.query(async ({ input }) => {
|
|
return db.getBountyComments(input.bountyId);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
bountyId: z.number(),
|
|
content: z.string().min(1),
|
|
parentId: z.number().optional(),
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.createBountyComment({
|
|
bountyId: input.bountyId,
|
|
userId: ctx.user.id,
|
|
content: input.content,
|
|
parentId: input.parentId,
|
|
});
|
|
|
|
// 通知悬赏发布者(如果评论者不是发布者)
|
|
const bounty = await db.getBountyById(input.bountyId);
|
|
if (bounty && bounty.bounty.publisherId !== ctx.user.id) {
|
|
await db.createNotification({
|
|
userId: bounty.bounty.publisherId,
|
|
type: 'new_comment',
|
|
title: '新评论',
|
|
content: `您的悬赏"${bounty.bounty.title}"收到了新评论`,
|
|
relatedId: input.bountyId,
|
|
relatedType: 'bounty'
|
|
});
|
|
}
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ Payment Routes ============
|
|
payment: router({
|
|
createEscrow: protectedProcedure
|
|
.input(z.object({ bountyId: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.bountyId);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以托管赏金' });
|
|
}
|
|
if (bounty.bounty.isEscrowed) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '赏金已经托管' });
|
|
}
|
|
|
|
const amount = Math.round(parseFloat(bounty.bounty.reward) * 100); // Convert to cents
|
|
const origin = ctx.req.headers.origin || 'http://localhost:3000';
|
|
|
|
const session = await createBountyEscrowCheckout({
|
|
bountyId: input.bountyId,
|
|
bountyTitle: bounty.bounty.title,
|
|
amount,
|
|
userId: ctx.user.id,
|
|
userEmail: ctx.user.email || '',
|
|
userName: ctx.user.name || '',
|
|
origin,
|
|
});
|
|
|
|
return { checkoutUrl: session.url };
|
|
}),
|
|
|
|
setupConnectAccount: protectedProcedure.mutation(async ({ ctx }) => {
|
|
// Check if user already has a connect account
|
|
const user = await db.getUserById(ctx.user.id);
|
|
if (user?.stripeAccountId) {
|
|
// Return account link for existing account
|
|
const origin = ctx.req.headers.origin || 'http://localhost:3000';
|
|
const accountLink = await createAccountLink({
|
|
accountId: user.stripeAccountId,
|
|
origin,
|
|
});
|
|
return { accountLinkUrl: accountLink.url };
|
|
}
|
|
|
|
// Create new connect account
|
|
const account = await createConnectAccount({
|
|
userId: ctx.user.id,
|
|
email: ctx.user.email || '',
|
|
});
|
|
|
|
// Save account ID to user
|
|
await db.updateUserStripeInfo(ctx.user.id, undefined, account.id);
|
|
|
|
// Create account link
|
|
const origin = ctx.req.headers.origin || 'http://localhost:3000';
|
|
const accountLink = await createAccountLink({
|
|
accountId: account.id,
|
|
origin,
|
|
});
|
|
|
|
return { accountLinkUrl: accountLink.url };
|
|
}),
|
|
|
|
getConnectStatus: protectedProcedure.query(async ({ ctx }) => {
|
|
const user = await db.getUserById(ctx.user.id);
|
|
if (!user?.stripeAccountId) {
|
|
return { hasAccount: false, chargesEnabled: false, payoutsEnabled: false };
|
|
}
|
|
|
|
const status = await getAccountStatus(user.stripeAccountId);
|
|
return {
|
|
hasAccount: true,
|
|
...status,
|
|
};
|
|
}),
|
|
|
|
releasePayout: protectedProcedure
|
|
.input(z.object({ bountyId: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const bounty = await db.getBountyById(input.bountyId);
|
|
if (!bounty) {
|
|
throw new TRPCError({ code: 'NOT_FOUND', message: '悬赏不存在' });
|
|
}
|
|
if (bounty.bounty.publisherId !== ctx.user.id) {
|
|
throw new TRPCError({ code: 'FORBIDDEN', message: '只有发布者可以释放赏金' });
|
|
}
|
|
if (bounty.bounty.status !== 'completed') {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '只有已完成的悬赏才能释放赏金' });
|
|
}
|
|
if (bounty.bounty.isPaid) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '赏金已经释放' });
|
|
}
|
|
if (!bounty.bounty.acceptorId) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '没有接单者' });
|
|
}
|
|
|
|
// Get acceptor's stripe account
|
|
const acceptor = await db.getUserById(bounty.bounty.acceptorId);
|
|
if (!acceptor?.stripeAccountId) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '接单者未设置收款账户' });
|
|
}
|
|
|
|
const amount = Math.round(parseFloat(bounty.bounty.reward) * 100 * 0.9); // 90% to acceptor
|
|
|
|
const transfer = await transferToConnectedAccount({
|
|
amount,
|
|
destinationAccountId: acceptor.stripeAccountId,
|
|
bountyId: input.bountyId,
|
|
});
|
|
|
|
// Update bounty
|
|
await db.updateBounty(input.bountyId, {
|
|
isPaid: true,
|
|
stripeTransferId: transfer.id,
|
|
});
|
|
|
|
// Notify acceptor
|
|
await db.createNotification({
|
|
userId: bounty.bounty.acceptorId,
|
|
type: 'payment_received',
|
|
title: '赏金已到账',
|
|
content: `悬赏“${bounty.bounty.title}”的赏金已转入您的账户`,
|
|
relatedId: input.bountyId,
|
|
relatedType: 'bounty',
|
|
});
|
|
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
|
|
// ============ Favorite Routes ============
|
|
favorite: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserFavorites(ctx.user.id);
|
|
}),
|
|
|
|
isFavorited: protectedProcedure
|
|
.input(z.object({ productId: z.number(), websiteId: z.number() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return db.isFavorited(ctx.user.id, input.productId, input.websiteId);
|
|
}),
|
|
|
|
add: protectedProcedure
|
|
.input(z.object({ productId: z.number(), websiteId: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const exists = await db.isFavorited(ctx.user.id, input.productId, input.websiteId);
|
|
if (exists) {
|
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '该商品已收藏' });
|
|
}
|
|
await db.addFavorite(ctx.user.id, input.productId, input.websiteId);
|
|
return { success: true };
|
|
}),
|
|
|
|
remove: protectedProcedure
|
|
.input(z.object({ productId: z.number(), websiteId: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.removeFavorite(ctx.user.id, input.productId, input.websiteId);
|
|
return { success: true };
|
|
}),
|
|
|
|
// Tag management
|
|
tags: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserFavoriteTags(ctx.user.id);
|
|
}),
|
|
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
name: z.string().min(1).max(100),
|
|
color: z.string().optional(),
|
|
description: z.string().optional()
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.createFavoriteTag(ctx.user.id, input.name, input.color, input.description);
|
|
return { success: true };
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
name: z.string().min(1).max(100).optional(),
|
|
color: z.string().optional(),
|
|
description: z.string().optional()
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { id, ...data } = input;
|
|
await db.updateFavoriteTag(id, ctx.user.id, data);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.deleteFavoriteTag(input.id, ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
addToFavorite: protectedProcedure
|
|
.input(z.object({ favoriteId: z.number(), tagId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.addTagToFavorite(input.favoriteId, input.tagId);
|
|
return { success: true };
|
|
}),
|
|
|
|
removeFromFavorite: protectedProcedure
|
|
.input(z.object({ favoriteId: z.number(), tagId: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.removeTagFromFavorite(input.favoriteId, input.tagId);
|
|
return { success: true };
|
|
}),
|
|
|
|
getByTag: protectedProcedure
|
|
.input(z.object({ tagId: z.number() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return db.getFavoritesByTag(ctx.user.id, input.tagId);
|
|
}),
|
|
}),
|
|
}),
|
|
|
|
// ============ Price Monitor Routes ============
|
|
priceMonitor: router({
|
|
create: protectedProcedure
|
|
.input(z.object({
|
|
favoriteId: z.number(),
|
|
currentPrice: z.number().optional(),
|
|
targetPrice: z.number().optional()
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.createPriceMonitor(ctx.user.id, input.favoriteId, input.currentPrice, input.targetPrice);
|
|
return { success: true };
|
|
}),
|
|
|
|
get: protectedProcedure
|
|
.input(z.object({ favoriteId: z.number() }))
|
|
.query(async ({ input, ctx }) => {
|
|
return db.getPriceMonitor(ctx.user.id, input.favoriteId);
|
|
}),
|
|
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserPriceMonitors(ctx.user.id);
|
|
}),
|
|
|
|
update: protectedProcedure
|
|
.input(z.object({
|
|
id: z.number(),
|
|
currentPrice: z.number().optional(),
|
|
targetPrice: z.number().optional(),
|
|
isActive: z.boolean().optional()
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const { id, ...data } = input;
|
|
const updateData: any = {};
|
|
if (data.currentPrice !== undefined) updateData.currentPrice = data.currentPrice.toString();
|
|
if (data.targetPrice !== undefined) updateData.targetPrice = data.targetPrice.toString();
|
|
if (data.isActive !== undefined) updateData.isActive = data.isActive;
|
|
await db.updatePriceMonitor(id, updateData);
|
|
return { success: true };
|
|
}),
|
|
|
|
delete: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input }) => {
|
|
await db.deletePriceMonitor(input.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
recordPrice: protectedProcedure
|
|
.input(z.object({
|
|
monitorId: z.number(),
|
|
price: z.number(),
|
|
previousPrice: z.number().optional()
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
await db.recordPriceHistory(input.monitorId, input.price, input.previousPrice);
|
|
return { success: true };
|
|
}),
|
|
|
|
history: protectedProcedure
|
|
.input(z.object({ monitorId: z.number(), limit: z.number().default(30) }))
|
|
.query(async ({ input }) => {
|
|
return db.getPriceHistory(input.monitorId, input.limit);
|
|
}),
|
|
|
|
batchCreate: protectedProcedure
|
|
.input(z.object({
|
|
favoriteIds: z.array(z.number()),
|
|
targetPrice: z.number()
|
|
}))
|
|
.mutation(async ({ input, ctx }) => {
|
|
const results = [];
|
|
for (const favId of input.favoriteIds) {
|
|
try {
|
|
await db.createPriceMonitor(ctx.user.id, favId, undefined, input.targetPrice);
|
|
results.push({ favId, success: true });
|
|
} catch (error) {
|
|
results.push({ favId, success: false, error: String(error) });
|
|
}
|
|
}
|
|
return { results, totalSuccess: results.filter(r => r.success).length };
|
|
}),
|
|
|
|
batchUpdate: protectedProcedure
|
|
.input(z.object({
|
|
monitorIds: z.array(z.number()),
|
|
targetPrice: z.number()
|
|
}))
|
|
.mutation(async ({ input }) => {
|
|
const results = [];
|
|
for (const monitorId of input.monitorIds) {
|
|
try {
|
|
await db.updatePriceMonitor(monitorId, { targetPrice: input.targetPrice.toString() });
|
|
results.push({ monitorId, success: true });
|
|
} catch (error) {
|
|
results.push({ monitorId, success: false, error: String(error) });
|
|
}
|
|
}
|
|
return { results, totalSuccess: results.filter(r => r.success).length };
|
|
}),
|
|
|
|
batchDelete: protectedProcedure
|
|
.input(z.object({ monitorIds: z.array(z.number()) }))
|
|
.mutation(async ({ input }) => {
|
|
const results = [];
|
|
for (const monitorId of input.monitorIds) {
|
|
try {
|
|
await db.deletePriceMonitor(monitorId);
|
|
results.push({ monitorId, success: true });
|
|
} catch (error) {
|
|
results.push({ monitorId, success: false, error: String(error) });
|
|
}
|
|
}
|
|
return { results, totalSuccess: results.filter(r => r.success).length };
|
|
}),
|
|
}),
|
|
|
|
// ============ Notification Routes ============
|
|
notification: router({
|
|
list: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUserNotifications(ctx.user.id);
|
|
}),
|
|
|
|
unreadCount: protectedProcedure.query(async ({ ctx }) => {
|
|
return db.getUnreadNotificationCount(ctx.user.id);
|
|
}),
|
|
|
|
markAsRead: protectedProcedure
|
|
.input(z.object({ id: z.number() }))
|
|
.mutation(async ({ input, ctx }) => {
|
|
await db.markNotificationAsRead(input.id, ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
|
|
markAllAsRead: protectedProcedure.mutation(async ({ ctx }) => {
|
|
await db.markAllNotificationsAsRead(ctx.user.id);
|
|
return { success: true };
|
|
}),
|
|
}),
|
|
});
|
|
|
|
export type AppRouter = typeof appRouter;
|