Skip to content
Merged
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
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,4 @@ android/.gradle/
postgres-data/
*.log

.idea/
.env
.env.*
!.env.example
.idea/
3 changes: 2 additions & 1 deletion apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { AccountModule } from './account/account.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ProductsModule } from './products/products.module';
import { SupabaseModule } from './supabase/supabase.module';

@Module({
imports: [SupabaseModule, AccountModule, NotificationsModule],
imports: [SupabaseModule, AccountModule, NotificationsModule, ProductsModule],
})
export class AppModule {}
4 changes: 4 additions & 0 deletions apps/api/src/app.setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';

export function configureApp(app: INestApplication) {
app.enableCors({
origin: true,
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }),
);
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/products/products.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { SupabaseAuthGuard } from '../auth/supabase-auth.guard';
import { ProductsService } from './products.service';

@Controller('products')
@UseGuards(SupabaseAuthGuard)
export class ProductsController {
constructor(private readonly products: ProductsService) {}

@Get()
list() {
return this.products.listWithStock();
}

@Get(':id/stock')
stock(@Param('id') id: string) {
return this.products.getStock(id);
}
}
12 changes: 12 additions & 0 deletions apps/api/src/products/products.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { SupabaseAuthGuard } from '../auth/supabase-auth.guard';
import { SupabaseModule } from '../supabase/supabase.module';
import { ProductsController } from './products.controller';
import { ProductsService } from './products.service';

@Module({
imports: [SupabaseModule],
controllers: [ProductsController],
providers: [ProductsService, SupabaseAuthGuard],
})
export class ProductsModule {}
75 changes: 75 additions & 0 deletions apps/api/src/products/products.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { ProductsService } from './products.service';

describe('ProductsService', () => {
it('preserves Shopify decimal-string prices', async () => {
const from = jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({
data: [
{
slug: 'poster',
name: 'Poster',
store: 'Shop of Magic',
shopify_url: 'https://example.com/product.js',
},
],
error: null,
}),
}),
});
const service = new ProductsService({ admin: { from } } as never);
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
available: true,
featured_image: null,
price: '29.99',
variants: [],
}),
{ status: 200 },
),
);

await expect(service.listWithStock()).resolves.toEqual([
expect.objectContaining({ price: '$29.99' }),
]);

fetchMock.mockRestore();
});

it('includes the price when the Shopify variant is out of stock', async () => {
const from = jest.fn().mockReturnValue({
select: jest.fn().mockReturnValue({
order: jest.fn().mockResolvedValue({
data: [
{
slug: 'poster',
name: 'Poster',
store: 'Shop of Magic',
shopify_url: 'https://example.com/product.js',
},
],
error: null,
}),
}),
});
const service = new ProductsService({ admin: { from } } as never);
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(
new Response(
JSON.stringify({
available: false,
featured_image: null,
price: '29.99',
variants: [{ id: 1, available: false, price: '29.99' }],
}),
{ status: 200 },
),
);

await expect(service.listWithStock()).resolves.toEqual([
expect.objectContaining({ available: false, price: '$29.99' }),
]);

fetchMock.mockRestore();
});
});
116 changes: 116 additions & 0 deletions apps/api/src/products/products.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import {
Injectable,
InternalServerErrorException,
Logger,
NotFoundException,
} from '@nestjs/common';
import { SupabaseService } from '../supabase/supabase.service';
import type { ShopifyProductResponse } from './shopify-product.types';

type ProductRow = {
slug: string;
name: string;
store: string;
shopify_url: string;
};

type TrackedProduct = {
id: string;
name: string;
store: string;
shopifyUrl: string;
};

export type ProductStockDto = {
id: string;
name: string;
store: string;
price: string;
available: boolean;
imageUrl: string | null;
checkedAt: string;
};

const FETCH_TIMEOUT_MS = 8_000;

function formatPrice(value: string | number): string {
const amount = typeof value === 'string' ? Number.parseFloat(value) : value;
return Number.isFinite(amount) ? `$${amount.toFixed(2)}` : '';
}

function normalizeImageUrl(src: string | null): string | null {
if (!src) return null;
return src.startsWith('//') ? `https:${src}` : src;
}

@Injectable()
export class ProductsService {
private readonly logger = new Logger(ProductsService.name);

constructor(private readonly supabase: SupabaseService) {}

private get db() {
return this.supabase.admin;
}

async listWithStock(): Promise<ProductStockDto[]> {
const tracked = await this.listTrackedProducts();
return Promise.all(tracked.map((p) => this.fetchStock(p)));
}

async getStock(slug: string): Promise<ProductStockDto> {
const tracked = await this.listTrackedProducts();
const product = tracked.find((p) => p.id === slug);
if (!product) throw new NotFoundException();
return this.fetchStock(product);
}

private async listTrackedProducts(): Promise<TrackedProduct[]> {
const { data, error } = await this.db
.from('products')
.select('slug, name, store, shopify_url')
.order('created_at', { ascending: true });

if (error) throw new InternalServerErrorException();

return (data as ProductRow[]).map((row) => ({
id: row.slug,
name: row.name,
store: row.store,
shopifyUrl: row.shopify_url,
}));
}

private async fetchStock(product: TrackedProduct): Promise<ProductStockDto> {
try {
const res = await fetch(product.shopifyUrl, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`Shopify responded ${res.status}`);

const data = (await res.json()) as ShopifyProductResponse;
const variant = data.variants?.[0];

return {
id: product.id,
name: product.name,
store: product.store,
price: formatPrice(variant?.price ?? data.price),
available: variant ? variant.available : data.available,
imageUrl: normalizeImageUrl(data.featured_image),
checkedAt: new Date().toISOString(),
};
} catch (error) {
this.logger.warn(`Stock check failed for ${product.id}: ${error}`);
return {
id: product.id,
name: product.name,
store: product.store,
price: '',
available: false,
imageUrl: null,
checkedAt: new Date().toISOString(),
};
}
}
}
14 changes: 14 additions & 0 deletions apps/api/src/products/shopify-product.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type ShopifyVariant = {
id: number;
available: boolean;
price: string | number;
};

export type ShopifyProductResponse = {
id: number;
title: string;
available: boolean;
price: string | number;
featured_image: string | null;
variants: ShopifyVariant[];
};
1 change: 0 additions & 1 deletion apps/api/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"target": "ES2023",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
Expand Down
32 changes: 30 additions & 2 deletions apps/mobile/__tests__/home-screen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,35 @@ jest.mock("../features/products/ProductCard", () => ({
},
}));

import { fireEvent, render } from "@testing-library/react-native";
import { fireEvent, render, waitFor } from "@testing-library/react-native";
import { Text as MockText } from "react-native";

jest.mock("../features/api/productsClient", () => ({
loadProducts: jest.fn().mockResolvedValue({
ok: true,
data: [
{
id: "popular-1",
name: "Popular Product 1",
store: "Amazon",
price: "$19.99",
available: true,
imageUrl: null,
checkedAt: "2026-11-08T00:00:00.000Z",
},
{
id: "popular-2",
name: "Popular Product 2",
store: "Target",
price: "$49.99",
available: false,
imageUrl: null,
checkedAt: "2026-11-08T00:00:00.000Z",
},
],
}),
}));

import { HomeScreen } from "../features/home/HomeScreen";

describe("HomeScreen", () => {
Expand All @@ -19,7 +45,9 @@ describe("HomeScreen", () => {
<HomeScreen onLogout={jest.fn()} onOpenNotifications={jest.fn()} />,
);

expect(screen.getAllByTestId("shared-product-card")).toHaveLength(4);
await waitFor(() =>
expect(screen.getAllByTestId("shared-product-card")).toHaveLength(4),
);
});

it("opens Watched Items from the homepage section", async () => {
Expand Down
Loading
Loading