Skip to content

Commit 90c6ea9

Browse files
committed
feat: add purchase statistics endpoints and update API version to 1.3.0
1 parent dab728c commit 90c6ea9

File tree

8 files changed

+263
-5
lines changed

8 files changed

+263
-5
lines changed

README.md

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,10 @@ The REST API exchanges all objects in JSON format. The API provides the followin
126126
- **GET `/api/purchases/refunded`** - Get a list of the authenticated tester's refunded purchases - requires read:api permission
127127
- Optional parameters: `?page=1&limit=10&sort=date&order=desc`
128128
- Response: `{success: boolean, data: [{id: string, date: string, order: string, description: string, amount: number}], total: number, page: number, limit: number}`
129+
130+
- **GET `/api/purchases/ready-to-refund`** - Get a list of purchases ready for refund (with feedback and publication) - requires read:api permission
131+
- Optional parameters: `?page=1&limit=10&sort=date&order=desc`
132+
- Response: `{success: boolean, data: [{id: string, date: string, order: string, description: string, amount: number, feedback: string, feedbackDate: string, publicationDate: string, publicationScreenShot: string}], total: number, page: number, limit: number}`
129133

130134
- **GET `/api/purchase-status`** - Get the status of all purchases with feedback/publication/refund status - requires read:api permission
131135
- Optional parameters: `?page=1&limit=10&sort=date&order=desc&limitToNotRefunded=false`
@@ -162,6 +166,17 @@ The REST API exchanges all objects in JSON format. The API provides the followin
162166
- **GET `/api/refund/:id`** - Get information about a specific refund - requires read:api permission
163167
- Response: `{success: boolean, data: {date: string, purchase: string, refundDate: string, amount: number, transactionId?: string}}`
164168

169+
### Statistics
170+
171+
- **GET `/api/stats/refund-balance`** - Get balance between purchases and refunds - requires read:api permission
172+
- Response: `{success: boolean, purchasedAmount: number, refundedAmount: number, balance: number}`
173+
174+
- **GET `/api/stats/refund-delay`** - Get statistics about refund delays - requires read:api permission
175+
- Response: `{success: boolean, data: [{purchaseId: string, purchaseAmount: number, refundAmount: number, delayInDays: number, purchaseDate: string, refundDate: string}], averageDelayInDays: number}`
176+
177+
- **GET `/api/stats/purchases`** - Get purchase statistics overview - requires read:api permission
178+
- Response: `{success: boolean, data: {totalPurchases: number, totalRefundedPurchases: number, totalRefundedAmount: number}}`
179+
165180
### Database Management
166181

167182
- **GET `/api/backup/json`** - Backup the database - requires backup:api permission
@@ -287,5 +302,5 @@ Add a new user (Admin menu, dark mode)
287302

288303
API
289304
<img width="1124" alt="image" src="https://github.com/user-attachments/assets/cb2d57f5-d18c-481b-ba22-ee3455fbb044" />
290-
````
305+
`````
291306

client/get-openapi.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ const options = {
88
format: "json",
99
info: {
1010
title: "Feedback Flow API",
11-
version: "1.2.0",
11+
version: "1.3.0",
1212
},
1313
definition: {
1414
openapi: "3.0.0",
1515
info: {
1616
title: "Feedback Flow API",
17-
version: "1.2.0",
17+
version: "1.3.0",
1818
},
1919
}, // You can move properties from definition here if needed
2020
apis: ["../cloudflare-worker/src/routes/index.ts"], // Path to the API docs

client/public/openapi.json

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.0.0",
33
"info": {
44
"title": "Feedback Flow API",
5-
"version": "1.2.0"
5+
"version": "1.3.0"
66
},
77
"paths": {
88
"/api/testers": {
@@ -1164,6 +1164,53 @@
11641164
}
11651165
}
11661166
},
1167+
"/api/stats/purchases": {
1168+
"get": {
1169+
"summary": "Get purchase statistics",
1170+
"description": "Returns purchase statistics for the authenticated user. Requires read permission.",
1171+
"tags": [
1172+
"Statistics"
1173+
],
1174+
"responses": {
1175+
"200": {
1176+
"description": "Successfully retrieved purchase statistics",
1177+
"content": {
1178+
"application/json": {
1179+
"schema": {
1180+
"type": "object",
1181+
"properties": {
1182+
"success": {
1183+
"type": "boolean",
1184+
"example": true
1185+
},
1186+
"data": {
1187+
"type": "object",
1188+
"properties": {
1189+
"totalPurchases": {
1190+
"type": "integer",
1191+
"example": 10
1192+
},
1193+
"totalRefundedPurchases": {
1194+
"type": "integer",
1195+
"example": 2
1196+
},
1197+
"totalRefundedAmount": {
1198+
"type": "number",
1199+
"example": 50
1200+
}
1201+
}
1202+
}
1203+
}
1204+
}
1205+
}
1206+
}
1207+
},
1208+
"403": {
1209+
"description": "Unauthorized request"
1210+
}
1211+
}
1212+
}
1213+
},
11671214
"/api/backup/json": {
11681215
"get": {
11691216
"summary": "Backup database to JSON",

cloudflare-worker/src/db/d1-db.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
IdMapping,
3131
Publication,
3232
Purchase,
33+
PurchasesStatisticsData,
3334
Refund,
3435
Tester,
3536
} from "../types/data";
@@ -867,6 +868,38 @@ export class CloudflareD1DB implements FeedbackFlowDB {
867868
pageInfo,
868869
} as PurchaseStatusResponse;
869870
},
871+
/**
872+
* Get some statistics of purchases for a tester
873+
* @param testerUuid UUID of the tester
874+
* @returns Promise<PurchasesStatisticsData> Statistics data for the tester's purchases
875+
*/
876+
getPurchaseStatistics: async (
877+
testerUuid: string,
878+
): Promise<PurchasesStatisticsData> => {
879+
const { results } = await this.db
880+
.prepare(
881+
`SELECT
882+
SUM(CASE WHEN refunded = 1 THEN 1 ELSE 0 END) as nb_refunded,
883+
SUM(CASE WHEN refunded = 0 THEN 1 ELSE 0 END) as nb_not_refunded,
884+
SUM(CASE WHEN refunded = 0 AND id IN (SELECT purchase_id FROM feedbacks) AND id IN (SELECT purchase_id FROM publications) THEN 1 ELSE 0 END) as nb_ready_for_refund,
885+
COUNT(*) as nb_total,
886+
SUM(CASE WHEN refunded = 1 THEN amount ELSE 0 END) as total_refunded_amount,
887+
SUM(CASE WHEN refunded = 0 THEN amount ELSE 0 END) as total_not_refunded_amount,
888+
SUM(amount) as total_amount
889+
FROM purchases WHERE tester_uuid = ?`
890+
).bind(testerUuid)
891+
.all();
892+
const stats = results[0];
893+
return {
894+
nbRefunded: stats.nb_refunded as number,
895+
nbNotRefunded: stats.nb_not_refunded as number,
896+
nbReadyForRefund: stats.nb_ready_for_refund as number,
897+
nbTotal: stats.nb_total as number,
898+
totalRefundedAmount: stats.total_refunded_amount as number,
899+
totalNotRefundedAmount: stats.total_not_refunded_amount as number,
900+
totalPurchaseAmount: stats.total_amount as number,
901+
} as PurchasesStatisticsData;
902+
},
870903
};
871904

872905
/**

cloudflare-worker/src/db/db.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
IdMapping,
3030
Publication,
3131
Purchase,
32+
PurchasesStatisticsData,
3233
Refund,
3334
Tester,
3435
} from "../types/data";
@@ -279,6 +280,14 @@ export interface PurchasesRepository {
279280
sort?: string,
280281
order?: string,
281282
): Promise<PurchaseStatusResponse>;
283+
284+
/**
285+
* Get some statistics of purchases for a tester
286+
* @param testerUuid UUID of the tester
287+
* @returns Promise<PurchasesStatisticsData> Statistics data for the tester's purchases
288+
*/
289+
getPurchaseStatistics(
290+
testerUuid: string): Promise<PurchasesStatisticsData>;
282291
}
283292

284293
export interface FeedbacksRepository {

cloudflare-worker/src/db/in-memory-db.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
import { v4 as uuidv4 } from "uuid";
2828

29-
import { Feedback, Publication, Purchase, Refund, Tester } from "../types/data";
29+
import { Feedback, Publication, Purchase, Refund, Tester, PurchasesStatisticsData } from "../types/data";
3030

3131
import { DATABASESCHEMA, DEFAULT_PAGINATION, FeedbackFlowDB, PaginatedResult, PurchaseStatus, PurchaseStatusResponse, PurchaseWithFeedback } from "./db";
3232

@@ -646,6 +646,51 @@ export class InMemoryDB implements FeedbackFlowDB {
646646
pageInfo,
647647
};
648648
return response;
649+
},
650+
/**
651+
* Get statistics about purchases
652+
* @param testerUuid UUID of the tester
653+
* @returns {PurchasesStatisticsData} Statistics data for the tester's purchases
654+
*/
655+
getPurchaseStatistics: async (testerUuid: string): Promise<PurchasesStatisticsData> => {
656+
const purchases = this.data.purchases.filter(
657+
(purchase) => purchase.testerUuid === testerUuid,
658+
);
659+
660+
const totalAmount = purchases.reduce((acc, purchase) => {
661+
return acc + purchase.amount;
662+
}, 0);
663+
664+
const nbRefunded = purchases.filter((p) => p.refunded).length;
665+
const nbNotRefunded = purchases.filter((p) => !p.refunded).length;
666+
const nbReadyForRefund = purchases.filter(
667+
(p) =>
668+
!p.refunded &&
669+
this.data.feedbacks.some((feedback) => feedback.purchase === p.id) &&
670+
this.data.publications.some(
671+
(publication) => publication.purchase === p.id,
672+
),
673+
).length;
674+
const nbTotal = purchases.length;
675+
const totalRefundedAmount = purchases
676+
.filter((p) => p.refunded)
677+
.reduce((acc, purchase) => {
678+
return acc + purchase.amount;
679+
}, 0);
680+
const totalNotRefundedAmount = purchases
681+
.filter((p) => !p.refunded)
682+
.reduce((acc, purchase) => {
683+
return acc + purchase.amount;
684+
}, 0);
685+
return {
686+
nbRefunded,
687+
nbNotRefunded,
688+
nbReadyForRefund,
689+
nbTotal,
690+
totalRefundedAmount,
691+
totalNotRefundedAmount,
692+
totalPurchaseAmount: totalAmount,
693+
} as PurchasesStatisticsData;
649694
}
650695
};
651696

cloudflare-worker/src/routes/index.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2228,6 +2228,99 @@ const statsRoutes = (router: Router, env: Env) => {
22282228
},
22292229
env.READ_PERMISSION,
22302230
);
2231+
/**
2232+
* @openapi
2233+
* /api/stats/purchases:
2234+
* get:
2235+
* summary: Get purchase statistics
2236+
* description: Returns purchase statistics for the authenticated user. Requires read permission.
2237+
* tags:
2238+
* - Statistics
2239+
* responses:
2240+
* 200:
2241+
* description: Successfully retrieved purchase statistics
2242+
* content:
2243+
* application/json:
2244+
* schema:
2245+
* type: object
2246+
* properties:
2247+
* success:
2248+
* type: boolean
2249+
* example: true
2250+
* data:
2251+
* type: object
2252+
* properties:
2253+
* totalPurchases:
2254+
* type: integer
2255+
* example: 10
2256+
* totalRefundedPurchases:
2257+
* type: integer
2258+
* example: 2
2259+
* totalRefundedAmount:
2260+
* type: number
2261+
* example: 50.00
2262+
* 403:
2263+
* description: Unauthorized request
2264+
*/
2265+
router.get("/api/stats/purchases", async () => {
2266+
const db = getDatabase(env);
2267+
2268+
// Get user ID from authenticated user
2269+
const userId = router.jwtPayload.sub;
2270+
2271+
if (!userId) {
2272+
return router.handleUnauthorizedRequest();
2273+
}
2274+
2275+
// Find tester by user ID
2276+
const testerUuid = await db.idMappings.getTesterUuid(userId);
2277+
2278+
if (!testerUuid) {
2279+
return new Response(
2280+
JSON.stringify({ success: false, error: "Unauthorized" }),
2281+
{
2282+
status: 403,
2283+
headers: {
2284+
...router.corsHeaders,
2285+
"Content-Type": "application/json",
2286+
},
2287+
},
2288+
);
2289+
}
2290+
try {
2291+
// Get purchase statistics
2292+
const purchaseStats = await db.purchases.getPurchaseStatistics(testerUuid);
2293+
2294+
return new Response(
2295+
JSON.stringify({
2296+
success: true,
2297+
data: purchaseStats,
2298+
}),
2299+
{
2300+
status: 200,
2301+
headers: {
2302+
...router.corsHeaders,
2303+
"Content-Type": "application/json",
2304+
},
2305+
},
2306+
);
2307+
} catch (error) {
2308+
return new Response(
2309+
JSON.stringify({
2310+
success: false,
2311+
error: `Error fetching purchase statistics: ${(error as Error).message}`,
2312+
}),
2313+
{
2314+
status: 500,
2315+
headers: {
2316+
...router.corsHeaders,
2317+
"Content-Type": "application/json",
2318+
},
2319+
},
2320+
);
2321+
}
2322+
2323+
},env.READ_PERMISSION);
22312324
};
22322325

22332326
/**

cloudflare-worker/src/types/data.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,19 @@ export interface RefundBalanceResponse {
316316
refundedAmount: number;
317317
balance: number;
318318
}
319+
320+
export interface PurchasesStatisticsData {
321+
nbRefunded: number;
322+
nbNotRefunded: number;
323+
nbReadyForRefund: number;
324+
nbTotal: number;
325+
totalRefundedAmount: number;
326+
totalNotRefundedAmount: number;
327+
totalPurchaseAmount: number;
328+
}
329+
330+
export interface PurchaseStatisticsResponse {
331+
success: boolean;
332+
data: PurchasesStatisticsData;
333+
error?: string;
334+
}

0 commit comments

Comments
 (0)