Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ backend/mail-service/certs/
*.p12
*.pfx
*.pem
.DS_Store
.DS_Store
/generated/prisma
43 changes: 39 additions & 4 deletions admin/src/routes/(app)/shop/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,16 @@
cost: string;
regions: string[];
maxPerUser: string;
eventHoursReduction: string;
}>({
shopId: null,
name: '',
description: '',
imageUrl: '',
cost: '',
regions: [],
maxPerUser: ''
maxPerUser: '',
eventHoursReduction: ''
});
let editingItemId = $state<number | null>(null);
let shopItemSaving = $state(false);
Expand Down Expand Up @@ -322,7 +324,8 @@
imageUrl: '',
cost: '',
regions: [],
maxPerUser: ''
maxPerUser: '',
eventHoursReduction: ''
};
editingItemId = null;
shopItemError = '';
Expand All @@ -338,7 +341,8 @@
imageUrl: item.imageUrl || '',
cost: item.cost.toString(),
regions: item.regions ?? [],
maxPerUser: item.maxPerUser?.toString() || ''
maxPerUser: item.maxPerUser?.toString() || '',
eventHoursReduction: item.eventHoursReduction?.toString() ?? ''
};
shopItemError = '';
shopItemSuccess = '';
Expand All @@ -356,13 +360,20 @@
return;
}

const eventHoursReduction = shopItemForm.eventHoursReduction.trim()
? parseFloat(shopItemForm.eventHoursReduction)
: editingItemId
? null
: undefined;

const payload = {
name: shopItemForm.name,
description: shopItemForm.description || undefined,
imageUrl: shopItemForm.imageUrl || undefined,
cost: parseFloat(shopItemForm.cost),
regions: shopItemForm.regions.length > 0 ? shopItemForm.regions : undefined,
maxPerUser: shopItemForm.maxPerUser ? parseInt(shopItemForm.maxPerUser) : undefined
maxPerUser: shopItemForm.maxPerUser ? parseInt(shopItemForm.maxPerUser) : undefined,
eventHoursReduction
};

try {
Expand Down Expand Up @@ -862,6 +873,24 @@
bind:value={shopItemForm.maxPerUser}
/>
</div>
<div class="space-y-2">
<label
class="text-sm font-medium text-ds-text-secondary"
for="{idPrefix}-event-hours-reduction"
>Event hour credit</label
>
<TextField
id="{idPrefix}-event-hours-reduction"
type="number"
step="0.1"
min="0"
placeholder="None"
bind:value={shopItemForm.eventHoursReduction}
/>
<p class="text-xs text-ds-text-placeholder m-0">
Hours credited toward the event goal when purchased (shop slug must match event slug).
</p>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-ds-text-secondary">Regions</label>
<div class="flex flex-wrap gap-3">
Expand Down Expand Up @@ -996,6 +1025,12 @@
>Max {item.maxPerUser}/user</span
>
{/if}
{#if item.eventHoursReduction}
<span
class="px-2 py-0.5 text-xs rounded bg-green-500/20 border border-green-400 text-green-700 dark:text-green-300"
>+{item.eventHoursReduction}h event credit</span
>
{/if}
{#if item.variants && item.variants.length > 0}
<span
class="px-2 py-0.5 text-xs rounded bg-blue-500/20 border border-blue-400 text-ds-link"
Expand Down
4 changes: 2 additions & 2 deletions backend/BACKEND.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,9 +425,9 @@ Managed by Prisma. Schema at `prisma/schema.prisma` with 30+ migrations.
| Model | Key Fields | Purpose |
|-------|------------|---------|
| **Shop** | slug, description, isActive, isPublic | Shop containers |
| **ShopItem** | shopId, name, cost, maxPerUser, isActive, imageUrl | Purchasable items |
| **ShopItem** | shopId, name, cost, maxPerUser, eventHoursReduction, isActive, imageUrl | Purchasable items (`eventHoursReduction` credits hours toward the event whose slug matches the shop slug) |
| **ShopItemVariant** | itemId, name, cost, isActive | Item variants |
| **Transaction** | userId, itemId, variantId, cost, isFulfilled | Purchase records |
| **Transaction** | userId, itemId, variantId, cost, eventHoursCredit, isFulfilled | Purchase records (`eventHoursCredit` stores per-purchase event goal credit from shop items) |
| **PinnedItem** | userId, itemId | User's pinned shop item |

### Other Models
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "shop_items" ADD COLUMN "event_hours_reduction" DOUBLE PRECISION;

-- AlterTable
ALTER TABLE "transactions" ADD COLUMN "event_hours_credit" DOUBLE PRECISION;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- This is an empty migration.
4 changes: 4 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ model ShopItem {
cost Float
regions String[] @default([])
maxPerUser Int? @map("max_per_user")
/// Hours credited toward the linked event's hour goal when a user buys this item (shop slug must match event slug).
eventHoursReduction Float? @map("event_hours_reduction")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
Expand Down Expand Up @@ -353,6 +355,8 @@ model Transaction {
eventId Int? @map("event_id")
itemDescription String @map("item_description") @db.VarChar(500)
cost Float
/// Hours credited toward the linked event goal at purchase time (from shop item eventHoursReduction).
eventHoursCredit Float? @map("event_hours_credit")
isFulfilled Boolean @default(false) @map("is_fulfilled")
fulfilledAt DateTime? @map("fulfilled_at")
refundedAt DateTime? @map("refunded_at")
Expand Down
6 changes: 6 additions & 0 deletions backend/src/events/dto/events-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,12 @@ export class TicketStatusResponse {

@ApiProperty()
approvedHours: number;

@ApiProperty({
description:
'Hours credited toward this event goal from event shop purchases (per-user)',
})
eventHoursCredit: number;
}

export class TicketTransactionResponse {
Expand Down
59 changes: 54 additions & 5 deletions backend/src/events/events.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,22 @@ export class EventsService {
}
}

// ── Event hour progress ──

async getEventHoursCredit(userId: number, eventSlug: string): Promise<number> {
const result = await this.prisma.transaction.aggregate({
where: {
userId,
kind: 'ShopItem',
refundedAt: null,
eventHoursCredit: { not: null },
item: { shop: { slug: eventSlug } },
},
_sum: { eventHoursCredit: true },
});
return Math.round((result._sum.eventHoursCredit ?? 0) * 10) / 10;
}

// ── Ticketing ──

async getTicketStatus(userId: number, slug: string) {
Expand All @@ -204,8 +220,11 @@ export class EventsService {
select: { transactionId: true },
});

const { balance, totalApprovedHours } =
await this.balanceService.getUserBalance(userId);
const [{ balance, totalApprovedHours }, eventHoursCredit] =
await Promise.all([
this.balanceService.getUserBalance(userId),
this.getEventHoursCredit(userId, event.slug),
]);

return {
slug: event.slug,
Expand All @@ -215,6 +234,7 @@ export class EventsService {
hasTicket: !!hasTicketTxn,
balance,
approvedHours: Math.round(totalApprovedHours * 10) / 10,
eventHoursCredit,
};
}

Expand Down Expand Up @@ -245,13 +265,42 @@ export class EventsService {
if (event.ticketThreshold !== null) {
const { totalApprovedHours } =
await this.balanceService.getUserBalance(userId);
if (totalApprovedHours < event.ticketThreshold) {
const eventHoursCredit = await this.getEventHoursCredit(
userId,
event.slug,
);
const totalEligibleHours = totalApprovedHours + eventHoursCredit;
if (totalEligibleHours < event.ticketThreshold) {
throw new BadRequestException(
`You need ${event.ticketThreshold} approved hours to buy a ticket. You have ${Math.round(totalApprovedHours * 10) / 10}.`,
`You need ${event.ticketThreshold} approved hours to buy a ticket. You have ${Math.round(totalEligibleHours * 10) / 10}.`,
);
}
}

// Sum up all event hours credits for this specific event and reduce the ticket cost
const eventHoursCredits = await this.prisma.transaction.findMany({
where: {
userId,
eventId: event.eventId,
eventHoursCredit: { not: null },
refundedAt: null,
},
select: { eventHoursCredit: true },
});
const totalEventHoursCredit = eventHoursCredits.reduce(
(sum, txn) => sum + (txn.eventHoursCredit ?? 0),
0,
);
const netTicketCost = Math.max(0, event.ticketCost! - totalEventHoursCredit);

// Verify the user has sufficient balance to pay for the ticket
const { balance } = await this.balanceService.getUserBalance(userId);
if (balance < netTicketCost) {
throw new BadRequestException(
`Insufficient balance. You need ${netTicketCost} hours to buy a ticket, but you have ${Math.round(balance * 10) / 10} hours.`,
);
}

let transaction;
try {
transaction = await this.prisma.$transaction(async (tx) => {
Expand All @@ -261,7 +310,7 @@ export class EventsService {
eventId: event.eventId,
kind: 'EventTicket',
itemDescription: `Ticket — ${event.title}`,
cost: event.ticketCost!,
cost: netTicketCost,
},
});
await tx.pinnedEvent.upsert({
Expand Down
5 changes: 5 additions & 0 deletions backend/src/shop/dto/create-item.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,9 @@ export class CreateItemDto {
@IsOptional()
@Min(1)
maxPerUser?: number;

@IsNumber()
@IsOptional()
@Min(0)
eventHoursReduction?: number;
}
8 changes: 8 additions & 0 deletions backend/src/shop/dto/shop-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ export class ShopItemResponse {
@ApiProperty({ type: Number, nullable: true })
maxPerUser: number | null;

@ApiProperty({
type: Number,
nullable: true,
description:
'Hours credited toward the linked event hour goal when purchased (shop slug must match event slug)',
})
eventHoursReduction: number | null;

@ApiProperty()
isActive: boolean;

Expand Down
5 changes: 5 additions & 0 deletions backend/src/shop/dto/update-item.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,9 @@ export class UpdateItemDto {
@IsBoolean()
@IsOptional()
isActive?: boolean;

@IsNumber()
@IsOptional()
@Min(0)
eventHoursReduction?: number | null;
}
17 changes: 16 additions & 1 deletion backend/src/shop/shop.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export class ShopService {
imageUrl: createItemDto.imageUrl,
cost: createItemDto.cost,
regions: createItemDto.regions ?? [],
eventHoursReduction: createItemDto.eventHoursReduction ?? null,
},
include: {
shop: { select: { slug: true } },
Expand Down Expand Up @@ -338,8 +339,21 @@ export class ShopService {
description += ` - ${item.description}`;
}

let eventHoursCredit: number | null = null;
let linkedEventId: number | null = null;
if (item.eventHoursReduction != null && item.eventHoursReduction > 0) {
const linkedEvent = await this.prisma.event.findUnique({
where: { slug: item.shop.slug },
select: { eventId: true },
});
if (linkedEvent) {
eventHoursCredit = item.eventHoursReduction;
linkedEventId = linkedEvent.eventId;
}
}

console.log(
`[Shop Purchase] Creating transaction for userId: ${userId}, itemId: ${itemId}, unitCost: ${cost}, quantity: ${quantity}, total: ${cost * quantity}`,
`[Shop Purchase] Creating transaction for userId: ${userId}, itemId: ${itemId}, unitCost: ${cost}, quantity: ${quantity}, total: ${cost * quantity}, eventHoursCredit: ${eventHoursCredit ?? 'none'}`,
);
const transaction = await this.balanceService.processPurchase({
userId,
Expand All @@ -349,6 +363,7 @@ export class ShopService {
itemDescription: description,
itemId,
variantId: variant?.variantId ?? null,
eventId: linkedEventId,
preCheck: async (tx) => {
if (maxPerUser !== null && maxPerUser > 0) {
const count = await tx.transaction.count({
Expand Down
Loading