Files
petfeed/src/trpc/routers/explore.ts
T
adminandClaude Sonnet 5 c6ffc1336e feat(sponsor): Stripe-based voluntary sponsoring with Sponsor badge (Gitea #22)
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>
2026-08-14 10:24:08 +02:00

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