Skip to content
Draft
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
14 changes: 9 additions & 5 deletions apps/expo/features/packs/components/PackCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { router } from 'expo-router';
import { isArray } from 'radash';
import { useRef } from 'react';
import { Image, Pressable, View } from 'react-native';
import { useDeletePack, usePackDetailsFromStore } from '../hooks';
import { useDeletePack, useDuplicatePack, usePackDetailsFromStore } from '../hooks';
import { usePackOwnershipCheck } from '../hooks/usePackOwnershipCheck';
import type { Pack, PackInStore } from '../types';

Expand All @@ -19,6 +19,7 @@ type PackCardProps = {

export function PackCard({ pack: packArg, onPress, isGenUI = false }: PackCardProps) {
const deletePack = useDeletePack();
const duplicatePack = useDuplicatePack();
const { colors } = useColorScheme();
const { showActionSheetWithOptions } = useActionSheet();
const alertRef = useRef<AlertRef>(null);
Expand All @@ -27,15 +28,15 @@ export function PackCard({ pack: packArg, onPress, isGenUI = false }: PackCardPr
const pack = (isOwnedByUser ? packFromStore : packArg) as Pack; // Use passed pack for non user owned pack.

const handleActionsPress = () => {
const options =
isOwnedByUser && !isGenUI
? ['View Details', 'Edit', 'Delete', 'Cancel']
: ['View Details', 'Cancel'];
const options = isOwnedByUser
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change removes the !isGenUI guard for owned packs, so in generative-UI contexts the action sheet will now show Edit/Delete. PackItemCard still hides Edit/Delete when isGenUI is true, so this is inconsistent and likely a regression in behavior. Consider preserving the previous isOwnedByUser && !isGenUI condition for showing Edit/Delete.

Suggested change
const options = isOwnedByUser
const isOwnedAndNotGenUI = isOwnedByUser && !isGenUI;
const options = isOwnedAndNotGenUI

Copilot uses AI. Check for mistakes.
? ['View Details', 'Edit', 'Delete', 'Cancel']
: ['View Details', 'Duplicate', 'Cancel'];

const cancelButtonIndex = options.length - 1;
const destructiveButtonIndex = options.indexOf('Delete');
const viewDetailsIndex = 0;
const editIndex = options.indexOf('Edit');
const duplicateIndex = options.indexOf('Duplicate');

showActionSheetWithOptions(
{
Expand Down Expand Up @@ -66,6 +67,9 @@ export function PackCard({ pack: packArg, onPress, isGenUI = false }: PackCardPr
case editIndex:
router.push({ pathname: '/pack/[id]/edit', params: { id: pack.id } });
break;
case duplicateIndex:
duplicatePack.mutate({ packId: pack.id });
break;
case destructiveButtonIndex:
alertRef.current?.alert({
title: 'Delete pack?',
Expand Down
1 change: 1 addition & 0 deletions apps/expo/features/packs/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * from './useCreatePackWithItems';
export * from './useCurrentPack';
export * from './useDeletePack';
export * from './useDeletePackItem';
export * from './useDuplicatePack';
export * from './useDetailedPacks';
export * from './useHasMinimumInventory';
export * from './useImagePicker';
Expand Down
51 changes: 51 additions & 0 deletions apps/expo/features/packs/hooks/useDuplicatePack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useAuth } from 'expo-app/features/auth/hooks/useAuth';
import { useRouter } from 'expo-router';

export function useDuplicatePack() {
const { token } = useAuth();
const queryClient = useQueryClient();
const router = useRouter();

return useMutation({
mutationFn: async ({
packId,
name,
}: {
packId: string;
name?: string;
}): Promise<{
id: string;
name: string;
description: string | null;
category: string;
totalWeight: number;
baseWeight: number;
}> => {
const response = await fetch(
`${process.env.EXPO_PUBLIC_API_URL}/api/packs/${packId}/duplicate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ name }),
},
);
Comment on lines +25 to +35
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useDuplicatePack uses a raw fetch call and manually passes the access token, bypassing the shared axiosInstance interceptors (auth header injection + token refresh + standard error handling). Using axiosInstance here would avoid auth edge cases (expired token) and keep request behavior consistent with other hooks.

Copilot uses AI. Check for mistakes.

if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to duplicate pack');
}

return response.json();
},
onSuccess: (data) => {
// Invalidate packs query to refresh the list
queryClient.invalidateQueries({ queryKey: ['packs'] });
Comment on lines +44 to +46
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After duplication you immediately navigate to /pack/[id], but pack ownership in the UI is determined by presence in packsStore (see usePackOwnershipCheck). Since this hook doesn’t update packsStore/packItemsStore, the duplicated pack will be treated as “not owned” until the next sync refresh, hiding Add/Edit actions. Consider updating the stores from the duplication response (and/or triggering an immediate store refresh) before navigating.

Suggested change
onSuccess: (data) => {
// Invalidate packs query to refresh the list
queryClient.invalidateQueries({ queryKey: ['packs'] });
onSuccess: async (data) => {
// Invalidate packs query to refresh the list
queryClient.invalidateQueries({ queryKey: ['packs'] });
// Ensure packs data (and any derived stores) are refreshed before navigation
await queryClient.refetchQueries({ queryKey: ['packs'] });

Copilot uses AI. Check for mistakes.
Comment on lines +45 to +46
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

queryClient.invalidateQueries({ queryKey: ['packs'] }) doesn’t match any existing packs-related React Query keys in the app (packs are primarily driven by Legend-State stores; React Query uses keys like ['allPacks'] and ['pack', id]). This invalidation is likely a no-op; either remove it or invalidate the actual relevant queries (and/or refresh the stores).

Suggested change
// Invalidate packs query to refresh the list
queryClient.invalidateQueries({ queryKey: ['packs'] });
// Invalidate packs queries to refresh the list and pack detail
queryClient.invalidateQueries({ queryKey: ['allPacks'] });
queryClient.invalidateQueries({ queryKey: ['pack', data.id] });

Copilot uses AI. Check for mistakes.
// Navigate to the new pack
router.push({ pathname: '/pack/[id]', params: { id: data.id } });
},
});
}
148 changes: 148 additions & 0 deletions packages/api/src/routes/packs/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,154 @@ packRoutes.openapi(deletePackRoute, async (c) => {
return c.json({ success: true }, 200);
});

// Duplicate a pack
const duplicatePackRoute = createRoute({
method: 'post',
path: '/{packId}/duplicate',
tags: ['Packs'],
summary: 'Duplicate pack',
description: 'Create a copy of a pack with all its items. The new pack will belong to the current user.',
security: [{ bearerAuth: [] }],
request: {
params: z.object({
packId: z.string().openapi({ example: 'p_123456' }),
}),
body: {
content: {
'application/json': {
schema: z.object({
name: z.string().optional().openapi({
example: 'My Copied Pack',
description: 'Optional name for the new pack. Defaults to "{original_name} (Copy)"',
}),
}),
},
},
},
},
responses: {
200: {
description: 'Pack duplicated successfully',
content: {
'application/json': {
schema: PackWithWeightsSchema,
},
},
},
404: {
description: 'Pack not found',
content: {
'application/json': {
schema: ErrorResponseSchema,
},
},
},
500: {
description: 'Internal server error',
content: {
'application/json': {
schema: ErrorResponseSchema,
},
},
},
},
});

packRoutes.openapi(duplicatePackRoute, async (c) => {
const db = createDb(c);
const auth = c.get('user');
const packId = c.req.param('packId');
const { name: customName } = await c.req.json().catch(() => ({}));
Comment on lines +248 to +305
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing API tests for packs routes, but this new POST /packs/:packId/duplicate behavior isn’t covered. Add tests that verify: duplicating a public pack creates a new private pack owned by the caller, all non-deleted items are copied with new IDs, and duplicating a non-public pack owned by someone else is rejected.

Copilot uses AI. Check for mistakes.

try {
// Get the source pack with items
const sourcePack = await db.query.packs.findFirst({
where: eq(packs.id, packId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});
Comment on lines +309 to +316
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The duplicate endpoint currently loads the source pack by ID without checking visibility/ownership or deleted status. That allows any authenticated user to duplicate private or deleted packs if they know/guess the ID. Consider restricting duplication to packs that are either owned by the current user or explicitly public, and ensure deleted = false on the source pack (and ideally its items) before copying.

Copilot uses AI. Check for mistakes.

if (!sourcePack) {
return c.json({ error: 'Pack not found' }, 404);
}

// Generate new pack ID
const newPackId = `p_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const now = new Date();

// Create the new pack
const [newPack] = await db
.insert(packs)
.values({
id: newPackId,
name: customName || `${sourcePack.name} (Copy)`,
description: sourcePack.description,
category: sourcePack.category,
userId: auth.userId,
isPublic: false, // Duplicated packs are private by default
image: sourcePack.image,
tags: sourcePack.tags,
deleted: false,
isAIGenerated: false,
localCreatedAt: now,
localUpdatedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
Comment on lines +326 to +345
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const [newPack] = ...returning() assigns newPack but the variable is never used. Either use it (e.g., to avoid the extra fetch) or remove the binding to avoid unused-var lint errors.

Copilot uses AI. Check for mistakes.

// Copy all items from source pack to new pack
if (sourcePack.items && sourcePack.items.length > 0) {
const newItems = sourcePack.items.map((item) => ({
id: `pi_${Date.now()}_${Math.random().toString(36).substring(2, 9)}_${Math.random().toString(36).substring(2, 5)}`,
name: item.name,
Comment on lines +322 to +351
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IDs for the new pack/items are generated with Date.now() + Math.random(), which are predictable and have a non-zero collision risk under concurrency. Prefer using crypto.randomUUID() (available in Workers) or an existing shared ID helper to generate unique, non-guessable IDs.

Copilot uses AI. Check for mistakes.
description: item.description,
weight: item.weight,
weightUnit: item.weightUnit,
quantity: item.quantity,
category: item.category,
consumable: item.consumable,
worn: item.worn,
image: item.image,
notes: item.notes,
packId: newPackId,
catalogItemId: item.catalogItemId,
userId: auth.userId,
deleted: false,
isAIGenerated: item.isAIGenerated,
templateItemId: item.templateItemId,
embedding: item.embedding,
createdAt: now,
updatedAt: now,
}));

await db.insert(packItems).values(newItems);
}

// Fetch the complete new pack with items
const packWithItems = await db.query.packs.findFirst({
where: eq(packs.id, newPackId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});

if (!packWithItems) {
return c.json({ error: 'Failed to retrieve duplicated pack' }, 500);
}

return c.json(computePackWeights(packWithItems), 200);
Comment on lines +308 to +389
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pack + items are inserted in separate statements without a transaction. If the item insert fails, you can end up with a partially duplicated pack (pack created, items missing). Wrapping the inserts and final read in a DB transaction would make this operation atomic.

Suggested change
// Get the source pack with items
const sourcePack = await db.query.packs.findFirst({
where: eq(packs.id, packId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});
if (!sourcePack) {
return c.json({ error: 'Pack not found' }, 404);
}
// Generate new pack ID
const newPackId = `p_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const now = new Date();
// Create the new pack
const [newPack] = await db
.insert(packs)
.values({
id: newPackId,
name: customName || `${sourcePack.name} (Copy)`,
description: sourcePack.description,
category: sourcePack.category,
userId: auth.userId,
isPublic: false, // Duplicated packs are private by default
image: sourcePack.image,
tags: sourcePack.tags,
deleted: false,
isAIGenerated: false,
localCreatedAt: now,
localUpdatedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
// Copy all items from source pack to new pack
if (sourcePack.items && sourcePack.items.length > 0) {
const newItems = sourcePack.items.map((item) => ({
id: `pi_${Date.now()}_${Math.random().toString(36).substring(2, 9)}_${Math.random().toString(36).substring(2, 5)}`,
name: item.name,
description: item.description,
weight: item.weight,
weightUnit: item.weightUnit,
quantity: item.quantity,
category: item.category,
consumable: item.consumable,
worn: item.worn,
image: item.image,
notes: item.notes,
packId: newPackId,
catalogItemId: item.catalogItemId,
userId: auth.userId,
deleted: false,
isAIGenerated: item.isAIGenerated,
templateItemId: item.templateItemId,
embedding: item.embedding,
createdAt: now,
updatedAt: now,
}));
await db.insert(packItems).values(newItems);
}
// Fetch the complete new pack with items
const packWithItems = await db.query.packs.findFirst({
where: eq(packs.id, newPackId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});
if (!packWithItems) {
return c.json({ error: 'Failed to retrieve duplicated pack' }, 500);
}
return c.json(computePackWeights(packWithItems), 200);
const duplicatedPackWithWeights = await db.transaction(async (tx) => {
// Get the source pack with items
const sourcePack = await tx.query.packs.findFirst({
where: eq(packs.id, packId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});
if (!sourcePack) {
// Throw to abort the transaction and be handled by the outer catch
throw new Error('Pack not found');
}
// Generate new pack ID
const newPackId = `p_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const now = new Date();
// Create the new pack
const [newPack] = await tx
.insert(packs)
.values({
id: newPackId,
name: customName || `${sourcePack.name} (Copy)`,
description: sourcePack.description,
category: sourcePack.category,
userId: auth.userId,
isPublic: false, // Duplicated packs are private by default
image: sourcePack.image,
tags: sourcePack.tags,
deleted: false,
isAIGenerated: false,
localCreatedAt: now,
localUpdatedAt: now,
createdAt: now,
updatedAt: now,
})
.returning();
// Copy all items from source pack to new pack
if (sourcePack.items && sourcePack.items.length > 0) {
const newItems = sourcePack.items.map((item) => ({
id: `pi_${Date.now()}_${Math.random().toString(36).substring(2, 9)}_${Math.random().toString(36).substring(2, 5)}`,
name: item.name,
description: item.description,
weight: item.weight,
weightUnit: item.weightUnit,
quantity: item.quantity,
category: item.category,
consumable: item.consumable,
worn: item.worn,
image: item.image,
notes: item.notes,
packId: newPackId,
catalogItemId: item.catalogItemId,
userId: auth.userId,
deleted: false,
isAIGenerated: item.isAIGenerated,
templateItemId: item.templateItemId,
embedding: item.embedding,
createdAt: now,
updatedAt: now,
}));
await tx.insert(packItems).values(newItems);
}
// Fetch the complete new pack with items
const packWithItems = await tx.query.packs.findFirst({
where: eq(packs.id, newPackId),
with: {
items: {
where: eq(packItems.deleted, false),
},
},
});
if (!packWithItems) {
throw new Error('Failed to retrieve duplicated pack');
}
return computePackWeights(packWithItems);
});
return c.json(duplicatedPackWithWeights, 200);

Copilot uses AI. Check for mistakes.
} catch (error) {
console.error('Error duplicating pack:', error);
return c.json({ error: 'Failed to duplicate pack' }, 500);
}
});

const itemSuggestionsRoute = createRoute({
method: 'post',
path: '/{packId}/item-suggestions',
Expand Down
Loading