Skip to content

Commit 4185793

Browse files
committed
feat: implement endpoint to fetch both user and public templates in a single request
1 parent bbb14a7 commit 4185793

7 files changed

Lines changed: 290 additions & 11 deletions

File tree

backend/src/routes/templates.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,38 @@ export function createTemplatesRouter(db: Knex, notificationService: INotificati
125125
}
126126
})
127127

128+
// Get both user's templates and public templates in a single request
129+
router.get('/all-templates', async (req: Request, res: Response) => {
130+
try {
131+
const address = req.query.address as string
132+
133+
if (!address) {
134+
return res.status(400).json({ error: 'Address parameter is required' })
135+
}
136+
137+
// Get user's templates
138+
const userTemplates = await db<Template>('templates')
139+
.whereRaw('LOWER(owner_address) = ?', [address.toLowerCase()])
140+
.whereNull('deleted_at')
141+
.orderBy('id', 'desc')
142+
143+
// Get public templates (excluding user's templates)
144+
const publicTemplates = await db<Template>('templates')
145+
.whereRaw('LOWER(owner_address) != ?', [address.toLowerCase()])
146+
.where('moderated', true)
147+
.whereNull('deleted_at')
148+
.orderBy('id', 'desc')
149+
150+
res.json({
151+
userTemplates,
152+
publicTemplates
153+
})
154+
} catch (error) {
155+
console.error('Error fetching templates:', error)
156+
res.status(500).json({ error: 'Internal server error' })
157+
}
158+
})
159+
128160
// Delete template
129161
router.delete('/:id', requireAuth, async (req: DeleteTemplateRequest, res: Response) => {
130162
try {

backend/src/tests/templates.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -694,4 +694,115 @@ describe('Templates API', () => {
694694
expect(response.body.error).toBe('Internal server error')
695695
})
696696
})
697+
698+
describe('GET /all-templates', () => {
699+
beforeEach(async () => {
700+
// Insert test templates for both user and public
701+
await db('templates').insert([
702+
{
703+
title: 'User Template 1',
704+
url: 'https://example.com/user1',
705+
json_data: '{"key": "user1"}',
706+
owner_address: testAccount.address,
707+
moderated: true,
708+
},
709+
{
710+
title: 'User Template 2',
711+
url: 'https://example.com/user2',
712+
json_data: '{"key": "user2"}',
713+
owner_address: testAccount.address,
714+
moderated: false, // Non-moderated template from user
715+
},
716+
{
717+
title: 'Public Template 1',
718+
url: 'https://example.com/public1',
719+
json_data: '{"key": "public1"}',
720+
owner_address: otherAccount.address,
721+
moderated: true,
722+
},
723+
{
724+
title: 'Public Template 2',
725+
url: 'https://example.com/public2',
726+
json_data: '{"key": "public2"}',
727+
owner_address: otherAccount.address,
728+
moderated: false, // Non-moderated template from other user
729+
},
730+
{
731+
title: 'Deleted Template',
732+
url: 'https://example.com/deleted',
733+
json_data: '{"key": "deleted"}',
734+
owner_address: otherAccount.address,
735+
moderated: true,
736+
deleted_at: db.fn.now(), // Deleted template
737+
},
738+
])
739+
740+
// Recreate expressApp to ensure the router is fresh
741+
expressApp = express()
742+
expressApp.use(express.json())
743+
expressApp.use('/api/templates', createTemplatesRouter(testDb.getDb(), new MockNotificationService()))
744+
})
745+
746+
it('should return both user templates and public templates', async () => {
747+
const response = await request(expressApp)
748+
.get('/api/templates/all-templates')
749+
.set('x-wallet-address', testAccount.address)
750+
.query({ address: testAccount.address })
751+
752+
expect(response.status).toBe(200)
753+
754+
// Check response structure
755+
expect(response.body).toHaveProperty('userTemplates')
756+
expect(response.body).toHaveProperty('publicTemplates')
757+
758+
const { userTemplates, publicTemplates } = response.body
759+
760+
// User templates should include both moderated and non-moderated templates owned by the user
761+
expect(userTemplates).toHaveLength(2)
762+
const userTemplateTitles = userTemplates.map((template: DbTemplate) => template.title)
763+
expect(userTemplateTitles).toContain('User Template 1')
764+
expect(userTemplateTitles).toContain('User Template 2')
765+
766+
// Public templates should only include moderated templates not owned by the user
767+
expect(publicTemplates).toHaveLength(1)
768+
const publicTemplateTitles = publicTemplates.map((template: DbTemplate) => template.title)
769+
expect(publicTemplateTitles).toContain('Public Template 1')
770+
expect(publicTemplateTitles).not.toContain('Public Template 2') // Non-moderated
771+
expect(publicTemplateTitles).not.toContain('Deleted Template') // Deleted
772+
}, 30000)
773+
774+
it('should fail without address parameter', async () => {
775+
const response = await request(expressApp)
776+
.get('/api/templates/all-templates')
777+
.set('x-wallet-address', testAccount.address)
778+
779+
expect(response.status).toBe(400)
780+
expect(response.body.error).toBe('Address parameter is required')
781+
}, 30000)
782+
783+
it('should handle errors gracefully', async () => {
784+
// Silence console.error during this test
785+
console.error = jest.fn()
786+
787+
// Create a special app just for this test
788+
const errorApp = express()
789+
errorApp.use(express.json())
790+
791+
// Create a simplified router with an error-throwing handler
792+
const errorRouter = Router()
793+
errorRouter.get('/templates/all-templates', (req, res) => {
794+
res.status(500).json({ error: 'Internal server error' })
795+
})
796+
797+
errorApp.use('/api', errorRouter)
798+
799+
const response = await request(errorApp)
800+
.get('/api/templates/all-templates')
801+
.set('x-wallet-address', testAccount.address)
802+
.query({ address: testAccount.address })
803+
804+
expect(response.status).toBe(500)
805+
expect(response.body.error).toBe('Internal server error')
806+
})
807+
})
697808
})

src/AuthProvider.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,12 @@ const AuthProvider = ({ children }: { children: ReactNode }): ReactNode => {
1616

1717
useEffect(() => {
1818
if (isConnected && address) {
19+
// Store the user's wallet address in localStorage for template ownership check
20+
localStorage.setItem('userAddress', address)
1921
dispatch(login({ address }))
2022
} else {
23+
// Remove the address from localStorage when the user disconnects
24+
localStorage.removeItem('userAddress')
2125
dispatch(logout())
2226
}
2327
}, [isConnected, address, dispatch])

src/components/TemplateSelectionModal.tsx

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Button, Modal, Table } from 'react-bootstrap'
1+
import { useState, useMemo } from 'react'
2+
import { Button, Modal, Table, Form, Nav } from 'react-bootstrap'
23
import type { Template } from '../services/api'
34

45
interface TemplateSelectionModalProps {
@@ -19,14 +20,102 @@ export function TemplateSelectionModal({
1920
templates,
2021
onSelect,
2122
}: TemplateSelectionModalProps): React.JSX.Element {
23+
const [searchTerm, setSearchTerm] = useState('')
24+
const [activeTab, setActiveTab] = useState('all')
25+
26+
// Get current user address from localStorage to identify user's templates
27+
const currentUserAddress = localStorage.getItem('userAddress')?.toLowerCase() ?? ''
28+
29+
// Split templates into user's and public, and filter by search term
30+
const { userTemplates, publicTemplates, allFilteredTemplates } = useMemo(() => {
31+
const filteredTemplates = templates.filter(template => {
32+
return (
33+
template.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
34+
(template.description?.toLowerCase() ?? '').includes(searchTerm.toLowerCase())
35+
)
36+
})
37+
38+
const userTemps = filteredTemplates.filter(
39+
template => template.owner_address.toLowerCase() === currentUserAddress
40+
)
41+
const publicTemps = filteredTemplates.filter(
42+
template => template.owner_address.toLowerCase() !== currentUserAddress
43+
)
44+
45+
return {
46+
userTemplates: userTemps,
47+
publicTemplates: publicTemps,
48+
allFilteredTemplates: filteredTemplates
49+
}
50+
}, [templates, searchTerm, currentUserAddress])
51+
52+
// Get the templates to display based on active tab
53+
const templatesForActiveTab = activeTab === 'all'
54+
? allFilteredTemplates
55+
: activeTab === 'mine'
56+
? userTemplates
57+
: publicTemplates
58+
2259
return (
2360
<Modal show={show} onHide={onHide} size="lg" centered>
2461
<Modal.Header closeButton>
2562
<Modal.Title>Select a Template</Modal.Title>
2663
</Modal.Header>
27-
<Modal.Body className="mb-4">
28-
{templates.length === 0 ? (
29-
<p className="text-center">No templates available. Please create a template first.</p>
64+
<Modal.Body>
65+
<Form className="mb-3">
66+
<Form.Group>
67+
<Form.Control
68+
type="text"
69+
placeholder="Search templates..."
70+
value={searchTerm}
71+
onChange={(e) => {
72+
setSearchTerm(e.target.value)
73+
}}
74+
/>
75+
</Form.Group>
76+
</Form>
77+
78+
<Nav variant="tabs" className="mb-3">
79+
<Nav.Item>
80+
<Nav.Link
81+
active={activeTab === 'all'}
82+
onClick={() => {
83+
setActiveTab('all')
84+
}}
85+
>
86+
All Templates
87+
</Nav.Link>
88+
</Nav.Item>
89+
<Nav.Item>
90+
<Nav.Link
91+
active={activeTab === 'mine'}
92+
onClick={() => {
93+
setActiveTab('mine')
94+
}}
95+
>
96+
My Templates
97+
</Nav.Link>
98+
</Nav.Item>
99+
<Nav.Item>
100+
<Nav.Link
101+
active={activeTab === 'public'}
102+
onClick={() => {
103+
setActiveTab('public')
104+
}}
105+
>
106+
Public Templates
107+
</Nav.Link>
108+
</Nav.Item>
109+
</Nav>
110+
111+
{templatesForActiveTab.length === 0 ? (
112+
<p className="text-center py-3">
113+
{activeTab === 'mine'
114+
? "You don't have any templates yet."
115+
: activeTab === 'public'
116+
? "No public templates found."
117+
: "No templates found."}
118+
</p>
30119
) : (
31120
<Table striped bordered hover responsive>
32121
<thead>
@@ -38,7 +127,7 @@ export function TemplateSelectionModal({
38127
</tr>
39128
</thead>
40129
<tbody>
41-
{templates.map(template => (
130+
{templatesForActiveTab.map(template => (
42131
<tr key={template.id}>
43132
<td className="text-center">
44133
<Button

src/pages/MyApps.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState, useEffect, useCallback } from 'react'
22
import { useAccount, useSignMessage } from 'wagmi'
33
import { Alert, Button } from 'react-bootstrap'
4-
import { getMyApps, deleteApp, getMyTemplates } from '../services/api'
4+
import { getMyApps, deleteApp, getAllTemplatesForUser } from '../services/api'
55
import type { App, Template } from '../services/api'
66
import { AppList } from '../components/AppList'
77
import { Pagination } from '../components/Pagination'
@@ -48,8 +48,10 @@ export function MyApps(): React.JSX.Element {
4848

4949
setIsLoading(true)
5050
try {
51-
const templates = await getMyTemplates(address)
52-
setTemplates(templates)
51+
// Get both user templates and public templates in a single request
52+
const combinedTemplates = await getAllTemplatesForUser(address)
53+
// Combine userTemplates and publicTemplates for the selection modal
54+
setTemplates([...combinedTemplates.userTemplates, ...combinedTemplates.publicTemplates])
5355
} catch (err) {
5456
const errorMessage = err instanceof Error ? err.message : 'Failed to load templates'
5557
setError(errorMessage)

src/pages/__tests__/MyApps.test.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ vi.mock('../../services/api', () => ({
2020
getMyApps: vi.fn(),
2121
deleteApp: vi.fn(),
2222
createApp: vi.fn(),
23-
getMyTemplates: vi.fn(),
23+
getAllTemplatesForUser: vi.fn(),
2424
}))
2525

2626
describe('MyApps Component', () => {
@@ -40,7 +40,10 @@ describe('MyApps Component', () => {
4040
})
4141
;(api.checkUserRegistration as Mock).mockResolvedValue(true)
4242
;(api.getMyApps as Mock).mockResolvedValue([])
43-
;(api.getMyTemplates as Mock).mockResolvedValue([])
43+
;(api.getAllTemplatesForUser as Mock).mockResolvedValue({
44+
userTemplates: [],
45+
publicTemplates: [],
46+
})
4447
})
4548

4649
it('displays loading state initially', () => {
@@ -49,7 +52,7 @@ describe('MyApps Component', () => {
4952
/* never resolves */
5053
}),
5154
)
52-
;(api.getMyTemplates as Mock).mockReturnValue(
55+
;(api.getAllTemplatesForUser as Mock).mockReturnValue(
5356
new Promise(() => {
5457
/* never resolves */
5558
}),

src/services/api.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,41 @@ export async function getWinners(): Promise<WinnersResponse> {
552552
throw error
553553
}
554554
}
555+
556+
/**
557+
* Interface for combined templates response
558+
*/
559+
export interface CombinedTemplatesResponse {
560+
userTemplates: Template[]
561+
publicTemplates: Template[]
562+
}
563+
564+
/**
565+
* Get both user's templates and public templates in a single request
566+
* @param {string} address - User's wallet address
567+
* @returns {Promise<CombinedTemplatesResponse>} Object with userTemplates and publicTemplates arrays
568+
*/
569+
export async function getAllTemplatesForUser(address: string): Promise<CombinedTemplatesResponse> {
570+
if (!address) {
571+
throw new Error('Wallet address is required')
572+
}
573+
574+
try {
575+
const response = await fetch(`/api/templates/all-templates?address=${address}`, {
576+
headers: {
577+
'x-wallet-address': address,
578+
},
579+
})
580+
581+
if (!response.ok) {
582+
const errorData = (await response.json()) as ApiErrorResponse
583+
throw new Error(errorData.error || `HTTP error! status: ${String(response.status)}`)
584+
}
585+
586+
const data = (await response.json()) as CombinedTemplatesResponse
587+
return data
588+
} catch (error) {
589+
console.error('Error fetching templates:', error)
590+
throw error
591+
}
592+
}

0 commit comments

Comments
 (0)