Replaces the (never-launched) ad business direction with a freiwilliger Beitrag/Community-Abo model: owners can support PawFeed via Stripe with a one-time payment or a monthly SEPA/card subscription (2 EUR minimum), declared as Sponsoring/Community-Abo, not a tax-deductible donation. The Sponsor badge (only perk, no feature unlocks) is Owner-scoped and shows on every post of every one of their pets, plus their profile. - Schema: Owner.stripeCustomerId/sponsorSince/sponsorPeriodEnd/ sponsorSubscriptionId/sponsorSubscriptionStatus, new SponsorContribution model (payment history) - src/lib/stripe.ts (singleton client, mux.ts pattern) + src/lib/sponsor.ts (isActiveSponsor: one-time givers keep the badge permanently, subscribers through sponsorPeriodEnd even after cancelling -- no mid-period cliff) - src/trpc/routers/sponsor.ts: getStatus, createCheckoutSession (SEPA + card for subscriptions, card for one-time, price_data since amounts are user-chosen), cancelSubscription (cancel_at_period_end, no hard stop) - src/app/api/webhooks/stripe: checkout.session.completed, invoice.paid, customer.subscription.updated/deleted (mux webhook route pattern) - src/app/(app)/unterstuetzen: amount chips (3/5/10 EUR) prefilling an editable field, one-time/subscription toggle, status + contribution history, in-app cancel button (no Stripe-hosted portal, per requirement) - Badge surfaced in feed/explore postInclude + PostCard, and on the pet profile page next to the existing isVerified badge - Nutzungsbedingungen Sec. 12 (draft, needs sign-off before launch) + Stripe added to the Datenschutzerklaerung's Auftragsverarbeiter list - src/__tests__/sponsor.test.ts (7 tests), Stripe client fully mocked STRIPE_WEBHOOK_SECRET intentionally not yet configured -- the webhook endpoint can only be registered in the Stripe dashboard once this is deployed and reachable at https://pawfeed.org/api/webhooks/stripe. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
import "server-only";
|
|
import { z } from "zod";
|
|
import { router, publicProcedure, rateLimited } from "@/trpc/init";
|
|
import { pickLocalizedName } from "@/lib/localized-name";
|
|
|
|
const TRENDING_POSTS_WINDOW_MS = 48 * 60 * 60 * 1000; // 48 h
|
|
const TRENDING_HASHTAGS_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
export const exploreRouter = router({
|
|
listPets: publicProcedure
|
|
.use(rateLimited("explore.listPets", 60, 60))
|
|
.input(
|
|
z.object({
|
|
speciesId: z.string().optional(),
|
|
breedId: z.string().optional(),
|
|
limit: z.number().int().min(1).max(40).default(20),
|
|
cursor: z.string().optional(), // petId cursor
|
|
})
|
|
)
|
|
.query(async ({ ctx, input }) => {
|
|
const pets = await ctx.prisma.pet.findMany({
|
|
where: {
|
|
...(input.speciesId ? { speciesId: input.speciesId } : {}),
|
|
...(input.breedId ? { breedId: input.breedId } : {}),
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: input.limit + 1,
|
|
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
|
|
include: {
|
|
species: { include: { translations: { where: { locale: ctx.locale } } } },
|
|
breed: { include: { translations: { where: { locale: ctx.locale } } } },
|
|
_count: { select: { followers: true } },
|
|
},
|
|
});
|
|
|
|
const hasNextPage = pets.length > input.limit;
|
|
const items = (hasNextPage ? pets.slice(0, input.limit) : pets).map((pet) => ({
|
|
...pet,
|
|
species: { ...pet.species, name: pickLocalizedName(pet.species.name, pet.species.translations, ctx.locale) },
|
|
breed: pet.breed
|
|
? { ...pet.breed, name: pickLocalizedName(pet.breed.name, pet.breed.translations, ctx.locale) }
|
|
: pet.breed,
|
|
}));
|
|
return {
|
|
pets: items,
|
|
nextCursor: hasNextPage ? items[items.length - 1]!.id : null,
|
|
};
|
|
}),
|
|
|
|
/**
|
|
* getTrending — trending posts (48 h window, ranked by reaction count)
|
|
* and trending hashtags (7 day window, ranked by post count).
|
|
*/
|
|
getTrending: publicProcedure
|
|
.use(rateLimited("explore.getTrending", 30, 60))
|
|
.query(async ({ ctx }) => {
|
|
const now = Date.now();
|
|
const since48h = new Date(now - TRENDING_POSTS_WINDOW_MS);
|
|
const since7d = new Date(now - TRENDING_HASHTAGS_WINDOW_MS);
|
|
|
|
const [posts, hashtags] = await Promise.all([
|
|
ctx.prisma.post.findMany({
|
|
where: {
|
|
hiddenAt: null,
|
|
type: "PHOTO",
|
|
pet: { owner: { shadowban: null } },
|
|
OR: [{ createdAt: { gte: since48h } }, { pinnedAt: { not: null } }],
|
|
},
|
|
include: {
|
|
images: { orderBy: { position: "asc" } },
|
|
pet: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
avatarKey: true,
|
|
owner: { select: { sponsorSince: true, sponsorPeriodEnd: true } },
|
|
},
|
|
},
|
|
milestone: true,
|
|
},
|
|
orderBy: [{ pinnedAt: { sort: "desc", nulls: "last" } }, { reactionCount: "desc" }],
|
|
take: 12,
|
|
}),
|
|
ctx.prisma.hashtag.findMany({
|
|
where: {
|
|
posts: { some: { post: { createdAt: { gte: since7d } } } },
|
|
},
|
|
include: { _count: { select: { posts: true } } },
|
|
orderBy: { posts: { _count: "desc" } },
|
|
take: 10,
|
|
}),
|
|
]);
|
|
|
|
return { posts, hashtags };
|
|
}),
|
|
});
|