Site Management
๐ Overviewโ
internal/api/v1/sites/services/sites.js manages the complete website lifecycle through integration with the Duda platform. It handles active site retrieval with smart caching, template browsing, publishing/unpublishing workflows, site deletion, analytics, SSO access, and form collection.
File Path: internal/api/v1/sites/services/sites.js
๐๏ธ Collections Usedโ
๐ Full Schema: See Database Collections Documentation
agency_websitesโ
- Operations: Read/Write for site management and caching
- Model:
shared/models/agency-website.js - Usage Context: Stores website records with status tracking, builder integration, and auto-refresh timestamps
agency_website_templatesโ
- Operations: Read for template browsing and site creation
- Model:
shared/models/agency-website-template.js - Usage Context: Available templates for creating new websites
instasitesโ
- Operations: Read via aggregation for multi-type site support
- Model:
shared/models/instasite.js - Usage Context: Quick landing pages linked to business contacts
contactsโ
- Operations: Aggregation pipeline for instasite association
- Model:
shared/models/contact.js - Usage Context: Business information for instasites lookup
_store.productsโ
- Operations: Read with price population
- Model:
shared/models/store-product.js - Usage Context: Website pricing information for template variations
๐ Data Flowโ
sequenceDiagram
participant Client
participant Controller
participant Service
participant Database
participant Duda
Client->>Controller: GET /sites/active
Controller->>Service: getActiveSites()
Service->>Database: Find published sites
loop For expired cache
Service->>Duda: GET /sites/{id}
Duda-->>Service: Site details
Service->>Duda: GET /content/{id}
Duda-->>Service: Site content
Service->>Database: Update + refresh_at
end
Service->>Database: Aggregate instasites
Service-->>Controller: Combined sites
Controller-->>Client: Sites array
๐ง Business Logic & Functionsโ
Site Retrieval & Cachingโ
getActiveSites({ account_id, business, authToken, additional_options })โ
Purpose: Retrieves all active (published) websites for an account, including both agency sites and instasites, with automatic cache refresh for stale data.
Parameters:
account_id(ObjectId) - Account identifierbusiness(Object) - Business information for instasite lookupbusiness.id(ObjectId) - Business ID for aggregation
authToken(String) - DashClicks authentication tokenadditional_options(Object, optional) - MongoDB projection for selective field loading (e.g., exclude thumbnails)
Returns: Promise<Array> - Combined array of agency websites and instasites
Business Logic Flow:
-
Query Agency Sites
- Find all
AgencyWebsiterecords with status 'PUBLISHED' - Apply
additional_optionsprojection if provided - Tag results with
type: 'agencysite'
- Find all
-
Check Cache Expiration
- For each agency site, check if
refresh_at <= Date.now() - Expired sites trigger Duda API refresh
- For each agency site, check if
-
Parallel Cache Refresh
- Batch expired sites into concurrent API calls
- Fetch site details:
GET ${DUDA_ENDPOINT}/sites/${builder_id} - Fetch site content:
GET ${DUDA_ENDPOINT}/content/${builder_id} - Update database with fresh data + new
refresh_at(+1 hour)
-
Aggregate Instasites
- Complex MongoDB pipeline to find instasites linked to account businesses
- Match business by ID, lookup instasites with status 'PUBLISHED'
- Tag results with
type: 'instasite'
-
Combine and Return
- Merge agency sites and instasites arrays
- Return unified website list
Key Business Rules:
- Cache duration: 1 hour per site
- Parallel processing: All refreshes run concurrently with
Promise.all() - Graceful degradation: Individual refresh failures don't block other sites
- Type distinction: Clear tagging for frontend filtering
Example Usage:
const sites = await siteService.getActiveSites({
account_id: req.account_id,
business: req.business,
authToken: req.authToken,
});
// Returns: [{ ...site, type: 'agencysite' }, { ...site, type: 'instasite' }]
Side Effects:
- โ ๏ธ Updates
agency_websitescollection with refreshed data - โ ๏ธ Makes external API calls to Duda platform
- โ ๏ธ May generate multiple concurrent HTTP requests
getVariations(account_id, search)โ
Purpose: Retrieves available website templates with pricing information. Returns unpublished work-in-progress site if one exists, otherwise shows template gallery.
Parameters:
account_id(ObjectId) - Account identifiersearch(String, optional) - Filter templates by title (case-insensitive regex)
Returns: Promise<{ data: Array, pricing: Object }> - Templates/site with pricing details
Business Logic Flow:
-
Fetch Pricing Data
- Query
StoreProductfor "Websites" product with platform_type 'dashclicks' - Populate prices array
- Filter prices by metadata:
type == 'informational' && location == 'store' - Extract price_ids and product_id
- Query
-
Check for Work-in-Progress
- Look for unpublished site belonging to account
- If found, return it as sole variation (user should continue editing)
- Populate template_id for display
-
Fetch Templates (if no unpublished site)
- Query all
AgencyWebsiteTemplaterecords - Apply search filter if provided:
title: { $regex: search, $options: 'i' }
- Query all
-
Enrich Template Data
- Add template title from
template_id.title - Construct Duda preview URLs for desktop/tablet/mobile
- Duplicate thumbnails/previews at root level (Fulfillment Center 3.0 compatibility)
- Add template title from
-
Validate Pricing
- Throw 404 error if no pricing data found
- Ensures website creation can proceed
Key Business Rules:
- Work-in-progress takes precedence over template gallery
- Preview URLs use Duda's preview system with device query parameters
- Pricing must exist or creation fails
- Search applies case-insensitive regex matching
Error Handling:
- Throws
notFound()if pricing data missing: "No prices found for the agency website"
Example Usage:
const { data, pricing } = await siteService.getVariations(account_id, 'restaurant');
// Returns: { data: [...templates], pricing: { price_ids: [...], product_id: '...' }}
Publishing & Unpublishingโ
publish({ id, account_id, authToken })โ
Purpose: Publishes an unpublished website, making it live. Prevents multiple published sites per account.
Parameters:
id(ObjectId) - Site ID to publishaccount_id(ObjectId) - Account identifierauthToken(String) - Authentication token
Returns: Promise<Object> - Published site data
Business Logic Flow:
-
Duplicate Check
- Query for existing published site on account
- Throw
badRequest()if found: "There is already an active site present on this account"
-
Find Target Site
- Query site by ID and account_id
- Throw
notFound()if not found: "The site does not exist"
-
Call Duda API
PUT ${DUDA_ENDPOINT}/sites/${builder_id}/publish- Uses dual authentication headers (Bearer + x-duda-token)
-
Update Database
- Set
site.status = 'PUBLISHED' - Save changes
- Set
-
Return Updated Site
- Convert to JSON and return
Key Business Rules:
- Only one published site allowed per account
- Site must belong to requesting account
- Requires system-level scope permission
Error Handling:
badRequest(400): Duplicate published sitenotFound(404): Site doesn't exist
Side Effects:
- โ ๏ธ Updates site status in database
- โ ๏ธ Makes external Duda API call
- โ ๏ธ TODO: Should send socket notification (commented in code)
unpublish(account_id, authToken)โ
Purpose: Unpublishes the currently published website, taking it offline.
Parameters:
account_id(ObjectId) - Account identifierauthToken(String) - Authentication token
Returns: Promise<Object> - Unpublished site data
Business Logic Flow:
-
Find Published Site
- Query for site with status 'PUBLISHED'
- Throw
notFound()if none found: "No active site found"
-
Call Duda API
PUT ${DUDA_ENDPOINT}/sites/${builder_id}/unpublish- Dual authentication headers
-
Update Database
- Set
site.status = 'UNPUBLISHED' - Save changes
- Set
-
Return Updated Site
Key Business Rules:
- Can only unpublish if published site exists
- Status change persisted before returning
Error Handling:
notFound(404): No active site to unpublish
Site Deletionโ
deleteSite({ account_id, account, authToken })โ
Purpose: Safely deletes an unpublished website from both DashClicks and Duda. Includes multiple safety checks.
Parameters:
account_id(ObjectId) - Account identifieraccount(Object) - Full account documentaccount.main(Boolean) - Must be true for deletion
authToken(String) - Authentication token
Returns: Promise<void>
Business Logic Flow:
-
Main Account Check
- Verify
account.main === true - Throw
forbidden()if false: "This account cannot perform this operation"
- Verify
-
Published Site Protection
- Check if any published site exists
- Throw
forbidden()if found: "Cannot perform this operation on the published site"
-
Find Unpublished Site
- Query for unpublished site
- Throw
notFound()if none: "Cannot find any unpublished website"
-
Attempt Unpublish (defensive)
- Try
PUT ${DUDA_ENDPOINT}/sites/${builder_id}/unpublish - Catch and ignore errors (site may already be unpublished)
- Try
-
Delete from Database
AgencyWebsite.deleteMany({ account_id })- Removes all site records for account
-
Delete from Duda
DELETE ${DUDA_ENDPOINT}/sites/${builder_id}- Permanently removes site from platform
Key Business Rules:
- Only main accounts can delete sites
- Cannot delete published sites (must unpublish first)
- Defensive unpublish attempt prevents errors
- Database deletion before Duda deletion
Error Handling:
forbidden(403): Not main account or published site existsnotFound(404): No unpublished site found
Known Issues:
- โ ๏ธ TODO: Should use MongoDB transaction to prevent data inconsistency if Duda deletion fails after database deletion
Analytics & Dataโ
analytics({ query, account_id, authToken })โ
Purpose: Retrieves website analytics data from Duda platform for the published site.
Parameters:
query(Object) - Analytics query parametersquery.limit(Number, optional) - Result limitquery.from(String, optional) - Start date (ISO format)query.to(String, optional) - End datequery.dimension(String, optional) - 'system' or 'geo'query.result(String, optional) - 'traffic' or 'activities'query.dateGranularity(String, optional) - 'DAYS', 'WEEKS', 'MONTHS', 'YEARS'
account_id(ObjectId) - Account identifierauthToken(String) - Authentication token
Returns: Promise<Object|Array> - Analytics data from Duda (raw passthrough)
Business Logic Flow:
-
Find Published Site
- Query for site with status 'PUBLISHED'
- Return empty array if none found (graceful degradation)
-
Call Duda Analytics API
GET ${DUDA_ENDPOINT}/sites/${builder_id}/analytics- Pass all query parameters through
- Use dual authentication headers
-
Return Raw Data
- Pass through Duda's response unchanged
Key Business Rules:
- Only published sites have analytics
- Graceful degradation: Returns
[]instead of error if no site - Query parameters forwarded directly to Duda
Error Handling:
- No explicit errors thrown - returns empty array if no site
- Duda API errors bubble up naturally
SSO & Formsโ
sso({ id, query, account_id, authToken })โ
Purpose: Generates a single sign-on URL for accessing the Duda site editor without separate authentication.
Parameters:
id(ObjectId) - Site IDquery(Object) - SSO query parametersquery.target(String, required) - Must be 'EDITOR'query.limit(Number, optional) - Token validity duration
account_id(ObjectId) - Account identifierauthToken(String) - Authentication token
Returns: Promise<Object> - Duda SSO response with URL and token
Business Logic Flow:
-
Find Site
- Query by ID and account_id (any status)
- Throw
notFound()if not found: "Site not found"
-
Call Duda SSO API
GET ${DUDA_ENDPOINT}/accounts/${builder_account}/sso- Include query params +
site_name: builder_id - Dual authentication headers
-
Return SSO Data
- Pass through Duda's response (URL, token, expires_at)
Key Business Rules:
- Works for both published and unpublished sites
- Target must be 'EDITOR' (uppercase)
- Token has expiration (configurable via limit param)
forms({ id, query, account_id, authToken })โ
Purpose: Retrieves form submission data collected from the website.
Parameters:
id(ObjectId) - Site IDquery(Object) - Additional query parameters for Dudaaccount_id(ObjectId) - Account identifierauthToken(String) - Authentication token
Returns: Promise<Object> - Form submissions from Duda
Business Logic Flow:
-
Find Site
- Query by ID and account_id
- Throw
notFound()if not found: "Site not found"
-
Call Duda Forms API
GET ${DUDA_ENDPOINT}/sites/${builder_id}/forms- Include query params +
site_name: builder_id - Dual authentication headers
-
Return Form Data
- Pass through Duda's response unchanged
๐ Integration Pointsโ
External Servicesโ
Duda Platformโ
- Endpoint:
process.env.DUDA_INTERNAL_ENDPOINT - Authentication: Dual token (Bearer + x-duda-token)
- Operations:
- Site publishing/unpublishing
- Content and detail retrieval
- Analytics data
- SSO link generation
- Form data collection
Authentication Headers:
{
authorization: `Bearer ${authToken}`,
'x-account-id': account_id.toString(),
'x-duda-token': process.env.DUDA_TOKEN
}
Internal Dependenciesโ
shared/models/agency-website.js- Site data modelshared/models/agency-website-template.js- Template modelshared/models/instasite.js- Landing page modelshared/utilities/catch-errors.js- Error helpers (forbidden, notFound, badRequest)axios- HTTP client for Duda API
Store Moduleโ
- Purpose: Website pricing information
- Integration: Query StoreProduct for "Websites" product
- Filter: Prices with
metadata.type == 'informational' && metadata.location == 'store'
๐งช Edge Cases & Special Handlingโ
Case: Cache Expired During High Trafficโ
Condition: Multiple sites need refresh simultaneously
Handling: Parallel processing with Promise.all(), errors don't block other sites
Performance: Prevents sequential bottleneck
Case: Unpublished Site as Templateโ
Condition: User has work-in-progress site
Handling: Return unpublished site instead of template gallery
Reason: User should continue editing rather than start new site
Case: No Pricing Dataโ
Condition: StoreProduct query returns empty or no informational prices
Handling: Throw notFound() to prevent site creation without pricing
Impact: Creation flow fails gracefully
Case: Duda API Timeoutโ
Condition: External API call times out or fails
Handling: Error bubbles up to controller, logged to Sentry
Recovery: Frontend can retry request
Case: Delete After Database Success But Duda Failureโ
Condition: Database deletion succeeds but Duda API call fails
Handling: Currently unhandled - creates orphaned Duda site
TODO: Wrap in MongoDB transaction for atomicity
โ ๏ธ Important Notesโ
- ๐จ Transaction Missing:
deleteSiteneeds transaction support (commented TODO) - ๐ Socket Notification:
publishshould send real-time notification (commented TODO) - ๐๏ธ Cache Strategy: 1-hour cache duration, stored in database not Redis
- ๐ Dual Auth: All Duda calls require both DashClicks token and Duda token
- ๐ Graceful Analytics: Returns empty array instead of error when no site found
- ๐ฏ Type Tagging: Sites tagged as 'agencysite' or 'instasite' for frontend filtering
- โก Parallel Refresh: Multiple site refreshes run concurrently for performance
๐ Related Documentationโ
- Parent Module: Sites Module
- Related Service: Template Management
- Controller:
internal/api/v1/sites/controllers/sites.js - Routes:
internal/api/v1/sites/routes/sites.js