EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
domains.js
Go to the documentation of this file.
1/**
2 * @file routes/domains.js
3 * @brief Domain management API endpoints
4 * @description Provides REST API for managing DNS domains across multiple registrars
5 * including Cloudflare (DNS-only), eNom, and Moniker. Supports domain registration,
6 * DNS record management, nameserver updates, domain locking, renewal operations, and
7 * synchronization with external registrar APIs.
8 *
9 * **Key Features:**
10 * - Multi-tenant domain isolation
11 * - Paginated domain listing with search and filters
12 * - Domain availability checking
13 * - Domain registration and renewal
14 * - DNS record CRUD operations
15 * - Domain locking/unlocking
16 * - Auth code retrieval for transfers
17 * - Auto-renewal configuration
18 * - Registrar data synchronization
19 * - Redis caching for performance
20 *
21 * **Supported Registrars:**
22 * - Cloudflare (DNS management only)
23 * - eNom (full domain registration)
24 * - Moniker (full domain registration)
25 * @author Independent Business Group
26 * @date 2026-03-11
27 * @module routes/domains
28 * @requires express
29 * @requires ../services/db
30 * @requires ../middleware/auth
31 * @requires ../middleware/tenant
32 * @requires ../services/cloudflare
33 */
34
35const express = require('express');
36const router = express.Router();
37const pool = require('../services/db');
38const authenticateToken = require('../middleware/auth');
39const { getTenantFilter, setTenantContext } = require('../middleware/tenant');
40const {
41 searchDomain,
42 registerDomain,
43 getDomainDetails,
44 createZone,
45 createDNSRecord,
46 updateDNSRecord,
47 deleteDNSRecord,
48 listDNSRecords,
49 getZoneId
50} = require('../services/cloudflare');
51
52// Apply authentication and tenant context to all routes
53router.use(authenticateToken, setTenantContext);
54
55/**
56 * @api {get} /domains Get all domains
57 * @apiName GetDomains
58 * @apiGroup Domains
59 * @apiDescription Retrieves paginated list of all domains visible to the authenticated
60 * tenant. Includes domains from Cloudflare DNS and registered domains from eNom/Moniker.
61 * Supports filtering by search term, status, and contract. Returns domain metadata including
62 * expiration dates, nameservers, and associated customer/contract information.
63 * @apiHeader {string} Authorization Bearer JWT token with tenant context
64 * @apiParam {number} [page=1] Page number for pagination (1-based)
65 * @apiParam {number} [limit=50] Items per page (max 100)
66 * @apiParam {string} [search] Filter by domain name (case-insensitive partial match)
67 * @apiParam {string} [status] Filter by domain status (Active, Expired, Pending, etc.)
68 * @apiParam {number} [contract_id] Filter by associated contract ID
69 * @apiSuccess {object[]} domains Array of domain objects
70 * @apiSuccess {number} domains.domain_id Unique domain identifier
71 * @apiSuccess {string} domains.domain_name Domain name (example.com)
72 * @apiSuccess {string} domains.registrar Registrar name (cloudflare|enom|moniker)
73 * @apiSuccess {string} domains.status Domain status
74 * @apiSuccess {Date} domains.registration_date Date domain was registered
75 * @apiSuccess {Date} domains.expiration_date Expiration date (null for DNS-only)
76 * @apiSuccess {string[]} domains.nameservers Array of nameserver hostnames
77 * @apiSuccess {Object} domains.metadata Additional domain metadata (JSONB)
78 * @apiSuccess {string} domains.customer_name Associated customer name
79 * @apiSuccess {string} domains.contract_title Associated contract title
80 * @apiSuccess {number} total Total number of domains matching filters
81 * @apiSuccess {number} page Current page number
82 * @apiSuccess {number} limit Items per page
83 *
84 * @apiError {String} error Error message
85 * @apiError {String} details Detailed error information
86 * @apiError {Number} 500 Server error during query execution
87 *
88 * @apiExample {curl} Example Request:
89 * curl -H "Authorization: Bearer eyJhbGc..." \
90 * "https://api.everydaytech.au/domains?page=1&limit=20&search=example"
91 *
92 * @apiExample {json} Success Response:
93 * HTTP/1.1 200 OK
94 * {
95 * "domains": [
96 * {
97 * "domain_id": 123,
98 * "domain_name": "example.com",
99 * "registrar": "cloudflare",
100 * "status": "Active",
101 * "registration_date": "2024-01-15T00:00:00Z",
102 * "expiration_date": null,
103 * "nameservers": ["ns1.cloudflare.com", "ns2.cloudflare.com"],
104 * "customer_name": "Acme Corp",
105 * "contract_title": "Web Hosting - Monthly"
106 * }
107 * ],
108 * "total": 145,
109 * "page": 1,
110 * "limit": 20
111 * }
112 *
113 * @since 2.0.0
114 * @see {@link module:services/cloudflare} for DNS management
115 * @see {@link module:middleware/tenant} for tenant filtering
116 */
117router.get('/', async (req, res) => {
118 try {
119 const { clause, params, nextParamIndex } = getTenantFilter(req, 'd');
120 const page = parseInt(req.query.page, 10) || 1;
121 const limit = parseInt(req.query.limit, 10) || 50;
122 const offset = (page - 1) * limit;
123 const search = req.query.search;
124 const status = req.query.status;
125 const contractId = req.query.contract_id;
126
127 let query = `
128 SELECT d.*, c.name as customer_name, ct.title as contract_title, t.subdomain as tenant_subdomain
129 FROM domains d
130 LEFT JOIN customers c ON d.customer_id = c.customer_id
131 LEFT JOIN contracts ct ON d.contract_id = ct.contract_id
132 LEFT JOIN tenants t ON d.tenant_id = t.tenant_id
133 `;
134 let countQuery = 'SELECT COUNT(*) FROM domains d';
135 const where = [];
136 let paramIndex = nextParamIndex;
137
138 // Tenant filter
139 if (clause) {
140 where.push(clause);
141 countQuery += ` WHERE ${clause}`;
142 }
143
144 // Search filter
145 if (search) {
146 where.push(`d.domain_name ILIKE $${paramIndex}`);
147 params.push(`%${search}%`);
148 paramIndex++;
149 }
150
151 // Status filter
152 if (status) {
153 where.push(`d.status = $${paramIndex}`);
154 params.push(status);
155 paramIndex++;
156 }
157
158 // Contract filter
159 if (contractId) {
160 where.push(`d.contract_id = $${paramIndex}`);
161 params.push(contractId);
162 paramIndex++;
163 }
164
165 if (where.length > 0) {
166 query += ' WHERE ' + where.join(' AND ');
167 if (!clause) countQuery += ' WHERE ' + where.slice(clause ? 1 : 0).join(' AND ');
168 }
169
170 query += ` ORDER BY d.expiration_date ASC LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
171 params.push(limit, offset);
172
173 const [domainsResult, countResult] = await Promise.all([
174 pool.query(query, params),
175 pool.query(countQuery, clause ? params.slice(0, params.length - 2) : [])
176 ]);
177
178 // Parse metadata if it's a string (should be automatic with Postgres JSONB, but ensure it)
179 const domains = domainsResult.rows.map(domain => {
180 if (typeof domain.metadata === 'string') {
181 try {
182 domain.metadata = JSON.parse(domain.metadata);
183 } catch (e) {
184 console.error(`[Domains API] Failed to parse metadata for ${domain.domain_name}:`, e.message);
185 domain.metadata = {};
186 }
187 }
188 if (typeof domain.nameservers === 'string') {
189 try {
190 domain.nameservers = JSON.parse(domain.nameservers);
191 } catch (e) {
192 domain.nameservers = [];
193 }
194 }
195
196 // Debug log auto_renew status for first few domains
197 if (domainsResult.rows.indexOf(domain) < 3) {
198 console.log(`[Domains API] ${domain.domain_name} metadata:`, JSON.stringify(domain.metadata));
199 }
200
201 return domain;
202 });
203
204 res.json({
205 domains,
206 total: parseInt(countResult.rows[0].count, 10),
207 page,
208 limit
209 });
210 } catch (err) {
211 console.error('Error fetching domains:', err);
212 res.status(500).json({ error: 'Server error', details: err.message });
213 }
214});
215
216/**
217 * @api {get} /domains/:id Get domain by ID
218 * @apiName GetDomainById
219 * @apiGroup Domains
220 * @apiDescription Retrieves detailed information for a specific domain by its ID.
221 * Includes domain details, associated customer and contract information, and renewal
222 * history. Only returns domains visible to the authenticated tenant.
223 * @apiHeader {string} Authorization Bearer JWT token with tenant context
224 * @apiParam {number} id Domain ID (URL parameter)
225 * @apiSuccess {object} domain Domain object with full details
226 * @apiSuccess {number} domain.domain_id Unique domain identifier
227 * @apiSuccess {string} domain.domain_name Domain name (example.com)
228 * @apiSuccess {string} domain.registrar Registrar name
229 * @apiSuccess {string} domain.status Domain status
230 * @apiSuccess {Date} domain.registration_date Registration date
231 * @apiSuccess {Date} domain.expiration_date Expiration date
232 * @apiSuccess {number} domain.customer_id Associated customer ID
233 * @apiSuccess {string} domain.customer_name Customer name
234 * @apiSuccess {number} domain.contract_id Associated contract ID
235 * @apiSuccess {string} domain.contract_title Contract title
236 * @apiSuccess {string} domain.billing_interval Contract billing interval
237 * @apiSuccess {Date} domain.contract_renewal_date Contract renewal date
238 * @apiSuccess {Object[]} renewals Array of renewal history records
239 * @apiSuccess {number} renewals.renewal_id Renewal record ID
240 * @apiSuccess {number} renewals.domain_id Domain ID
241 * @apiSuccess {Date} renewals.renewed_at Renewal timestamp
242 * @apiSuccess {number} renewals.years_renewed Number of years renewed
243 * @apiSuccess {number} renewals.cost Renewal cost
244 * @apiError {String} error Error message
245 * @apiError {Number} 404 Domain not found or not visible to tenant
246 * @apiError {Number} 500 Server error during query execution
247 *
248 * @apiExample {curl} Example Request:
249 * curl -H "Authorization: Bearer eyJhbGc..." \
250 * "https://api.everydaytech.au/domains/123"
251 *
252 * @apiExample {json} Success Response:
253 * HTTP/1.1 200 OK
254 * {
255 * "domain": {
256 * "domain_id": 123,
257 * "domain_name": "example.com",
258 * "registrar": "enom",
259 * "status": "Active",
260 * "expiration_date": "2027-03-15T00:00:00Z",
261 * "customer_name": "Acme Corp",
262 * "contract_title": "Domain Management"
263 * },
264 * "renewals": [
265 * {
266 * "renewal_id": 456,
267 * "renewed_at": "2026-03-10T14:32:00Z",
268 * "years_renewed": 1,
269 * "cost": 15.99
270 * }
271 * ]
272 * }
273 *
274 * @since 2.0.0
275 */
276router.get('/:id', async (req, res) => {
277 const { id } = req.params;
278 try {
279 const { clause, params } = getTenantFilter(req, 'd');
280 let query = `
281 SELECT d.*, c.name as customer_name, ct.title as contract_title,
282 ct.billing_interval, ct.renewal_date as contract_renewal_date,
283 t.subdomain as tenant_subdomain
284 FROM domains d
285 LEFT JOIN customers c ON d.customer_id = c.customer_id
286 LEFT JOIN contracts ct ON d.contract_id = ct.contract_id
287 LEFT JOIN tenants t ON d.tenant_id = t.tenant_id
288 WHERE d.domain_id = $1
289 `;
290 const queryParams = [id];
291
292 if (clause) {
293 query += ` AND ${clause}`;
294 queryParams.push(...params);
295 }
296
297 const result = await pool.query(query, queryParams);
298
299 if (result.rows.length === 0) {
300 return res.status(404).json({ error: 'Domain not found' });
301 }
302
303 // Get renewal history
304 const renewals = await pool.query(
305 'SELECT * FROM domain_renewals WHERE domain_id = $1 ORDER BY renewed_at DESC',
306 [id]
307 );
308
309 res.json({
310 domain: result.rows[0],
311 renewals: renewals.rows
312 });
313 } catch (err) {
314 console.error('Error fetching domain:', err);
315 res.status(500).json({ error: 'Server error', details: err.message });
316 }
317});
318
319/**
320 * @api {post} /domains/search Check domain availability
321 * @apiName SearchDomain
322 * @apiGroup Domains
323 * @apiDescription Checks if a domain name is available for registration. First checks
324 * the local database to see if domain is already owned by the tenant, then queries the
325 * Cloudflare Registrar API to check availability and pricing. Returns availability status
326 * and registration pricing if available.
327 * @apiHeader {string} Authorization Bearer JWT token with tenant context
328 * @apiHeader {string} Content-Type application/json
329 * @apiParam {string} domain Domain name to check (e.g., "example.com")
330 * @apiSuccess {string} domain Domain name checked (normalized to lowercase)
331 * @apiSuccess {boolean} available True if domain is available for registration
332 * @apiSuccess {boolean} [alreadyOwned] True if domain exists in tenant's account
333 * @apiSuccess {string} [message] Human-readable availability message
334 * @apiSuccess {number} [price] Registration price (if available)
335 * @apiSuccess {string} [currency] Price currency code (USD, EUR, etc.)
336 * @apiSuccess {boolean} [premium] True if domain is premium priced
337 * @apiError {string} error Error message
338 * @apiError {number} 400 Domain name is required
339 * @apiError {number} 500 Domain search failed
340 * @apiExample {curl} Example Request:
341 * curl -X POST \
342 * -H "Authorization: Bearer eyJhbGc..." \
343 * -H "Content-Type: application/json" \
344 * -d '{"domain": "example.com"}' \
345 * "https://api.everydaytech.au/domains/search"
346 * @apiExample {json} Available Domain Response:
347 * HTTP/1.1 200 OK
348 * {
349 * "domain": "example.com",
350 * "available": true,
351 * "price": 12.99,
352 * "currency": "USD",
353 * "premium": false
354 * }
355 * @apiExample {json} Already Owned Response:
356 * HTTP/1.1 200 OK
357 * {
358 * "domain": "example.com",
359 * "available": false,
360 * "alreadyOwned": true,
361 * "message": "Domain already registered in your account"
362 * }
363 *
364 * @since 2.0.0
365 * @see {@link module:services/cloudflare.searchDomain} for Cloudflare API integration
366 */
367router.post('/search', async (req, res) => {
368 const { domain } = req.body;
369
370 if (!domain) {
371 return res.status(400).json({ error: 'Domain name is required' });
372 }
373
374 try {
375 // Check if domain exists in our database first
376 const existing = await pool.query(
377 'SELECT domain_id FROM domains WHERE domain_name = $1',
378 [domain.toLowerCase()]
379 );
380
381 if (existing.rows.length > 0) {
382 return res.json({
383 domain: domain.toLowerCase(),
384 available: false,
385 alreadyOwned: true,
386 message: 'Domain already registered in your account'
387 });
388 }
389
390 // Search Cloudflare Registrar API
391 const searchResult = await searchDomain(domain.toLowerCase());
392
393 res.json(searchResult);
394 } catch (err) {
395 console.error('Error searching domain:', err);
396 res.status(500).json({ error: 'Domain search failed', details: err.message });
397 }
398});
399
400/**
401 * @api {post} /domains Register new domain
402 * @apiName RegisterDomain
403 * @apiGroup Domains
404 * @apiDescription Registers a new domain through the configured registrar (Cloudflare).
405 * Creates domain zone, adds initial DNS records, and stores domain information in the
406 * database. Supports association with customer and contract for billing purposes.
407 *
408 * **Process Flow:**
409 * 1. Validates domain name format
410 * 2. Checks if domain already exists in database
411 * 3. Registers domain with Cloudflare Registrar API
412 * 4. Creates DNS zone in Cloudflare
413 * 5. Adds initial DNS records (if provided)
414 * 6. Stores domain record in database
415 * 7. Associates with customer/contract (if provided)
416 * @apiHeader {string} Authorization Bearer JWT token with tenant context
417 * @apiHeader {string} Content-Type application/json
418 * @apiParam {string} domain Domain name to register (e.g., "example.com")
419 * @apiParam {number} [customer_id] Associate domain with customer ID
420 * @apiParam {number} [contract_id] Associate domain with contract ID
421 * @apiParam {number} years Registration period in years (default: 1)
422 * @apiParam {object} [contacts] Domain contact information
423 * @apiParam {string} contacts.registrant Registrant contact details
424 * @apiParam {string} contacts.admin Admin contact details
425 * @apiParam {string} contacts.tech Technical contact details
426 * @apiParam {string} contacts.billing Billing contact details
427 * @apiParam {Object[]} [dnsRecords] Initial DNS records to create
428 * @apiParam {string} dnsRecords.type Record type (A, CNAME, MX, TXT, etc.)
429 * @apiParam {string} dnsRecords.name Record name/hostname
430 * @apiParam {string} dnsRecords.content Record value/content
431 * @apiParam {number} [dnsRecords.ttl=3600] TTL in seconds
432 * @apiParam {boolean} [autoRenew=true] Enable auto-renewal
433 * @apiSuccess {string} message Success message
434 * @apiSuccess {Object} domain Registered domain object
435 * @apiSuccess {Number} domain.domain_id Database domain ID
436 * @apiSuccess {String} domain.domain_name Registered domain name
437 * @apiSuccess {String} domain.registrar Registrar used (cloudflare)
438 * @apiSuccess {String} domain.status Domain status (Active)
439 * @apiSuccess {Date} domain.registration_date Registration timestamp
440 * @apiSuccess {Date} domain.expiration_date Expiration date
441 * @apiSuccess {String} domain.cloudflare_zone_id Cloudflare zone ID
442 *
443 * @apiError {String} error Error message
444 * @apiError {Number} 400 Invalid domain name or parameters
445 * @apiError {Number} 409 Domain already exists
446 * @apiError {Number} 500 Registration failed
447 *
448 * @apiExample {curl} Example Request:
449 * curl -X POST \
450 * -H "Authorization: Bearer eyJhbGc..." \
451 * -H "Content-Type: application/json" \
452 * -d '{
453 * "domain": "example.com",
454 * "customer_id": 42,
455 * "years": 1,
456 * "dnsRecords": [
457 * {"type": "A", "name": "@", "content": "192.0.2.1"},
458 * {"type": "CNAME", "name": "www", "content": "example.com"}
459 * ]
460 * }' \
461 * "https://api.everydaytech.au/domains"
462 *
463 * @apiExample {json} Success Response:
464 * HTTP/1.1 201 Created
465 * {
466 * "message": "Domain registered successfully",
467 * "domain": {
468 * "domain_id": 789,
469 * "domain_name": "example.com",
470 * "registrar": "cloudflare",
471 * "status": "Active",
472 * "registration_date": "2026-03-11T10:30:00Z",
473 * "expiration_date": "2027-03-11T10:30:00Z",
474 * "cloudflare_zone_id": "a1b2c3d4e5f6"
475 * }
476 * }
477 *
478 * @since 2.0.0
479 * @see {@link module:services/cloudflare.registerDomain} for Cloudflare integration
480 * @see {@link module:services/cloudflare.createZone} for DNS zone creation
481 */
482router.post('/', async (req, res) => {
483 const {
484 customer_id,
485 domain_name,
486 contract_id,
487 registrar = 'cloudflare',
488 registration_date,
489 expiration_date,
490 auto_renew = true,
491 whois_privacy = true,
492 purchase_price,
493 renewal_price,
494 currency = 'USD',
495 registrant_contact,
496 notes,
497 register_now = false,
498 create_zone = true // Create DNS zone in Cloudflare
499 } = req.body;
500
501 if (!domain_name) {
502 return res.status(400).json({ error: 'Domain name is required' });
503 }
504
505 try {
506 let registrationData = {
507 status: 'pending',
508 registration_date: registration_date || new Date(),
509 expiration_date: expiration_date,
510 zone_id: null,
511 nameservers: null
512 };
513
514 // If register_now is true and registrar is cloudflare, attempt registration
515 if (register_now && registrar === 'cloudflare') {
516 try {
517 const cloudflareResult = await registerDomain({
518 domain: domain_name.toLowerCase(),
519 years: 1,
520 privacy: whois_privacy,
521 auto_renew: auto_renew,
522 registrant_contact: registrant_contact
523 });
524
525 registrationData = {
526 status: cloudflareResult.status || 'active',
527 registration_date: cloudflareResult.registration_date || new Date(),
528 expiration_date: cloudflareResult.expiration_date,
529 zone_id: registrationData.zone_id,
530 nameservers: registrationData.nameservers
531 };
532
533 console.log(`[Domains] Successfully registered ${domain_name} with Cloudflare`);
534 } catch (registrationErr) {
535 // If registration fails, still create the domain record but mark as failed
536 console.error('[Domains] Cloudflare registration failed:', registrationErr.message);
537
538 // If API not available, allow manual registration
539 if (registrationErr.code === 'API_NOT_AVAILABLE') {
540 registrationData.status = 'manual_required';
541 registrationData.notes = (notes || '') + '\n\nCloudflare API not available. Manual registration required.';
542 } else {
543 return res.status(500).json({
544 error: 'Domain registration failed',
545 details: registrationErr.message
546 });
547 }
548 }
549 }
550
551 // Create DNS zone in Cloudflare if requested
552 if (create_zone && registrar === 'cloudflare') {
553 try {
554 const zoneResult = await createZone(domain_name.toLowerCase(), { jump_start: true });
555 registrationData.zone_id = zoneResult.zone_id;
556 registrationData.nameservers = zoneResult.nameservers;
557 console.log(`[Domains] Created Cloudflare zone for ${domain_name} (ID: ${zoneResult.zone_id})`);
558 } catch (zoneErr) {
559 console.warn(`[Domains] Zone creation failed for ${domain_name}:`, zoneErr.message);
560 // Continue even if zone creation fails - zone might already exist
561 if (zoneErr.response?.data?.errors?.[0]?.code === 1061 || zoneErr.message.includes('already exists')) {
562 // Zone already exists, try to get zone ID
563 const existingZoneId = await getZoneId(domain_name.toLowerCase());
564 if (existingZoneId) {
565 registrationData.zone_id = existingZoneId;
566 console.log(`[Domains] Using existing zone ${existingZoneId} for ${domain_name}`);
567 }
568 }
569 }
570 }
571
572 // Insert domain into database
573 const result = await pool.query(
574 `INSERT INTO domains (
575 tenant_id, customer_id, contract_id, domain_name, registrar, status,
576 registration_date, expiration_date, auto_renew, whois_privacy,
577 purchase_price, renewal_price, currency, registrant_contact, notes,
578 registrar_domain_id, nameservers
579 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
580 RETURNING *`,
581 [
582 req.tenant.id,
583 customer_id || null,
584 contract_id || null,
585 domain_name.toLowerCase(),
586 registrar,
587 registrationData.status,
588 registrationData.registration_date,
589 registrationData.expiration_date,
590 auto_renew,
591 whois_privacy,
592 purchase_price,
593 renewal_price,
594 currency,
595 registrant_contact ? JSON.stringify(registrant_contact) : null,
596 registrationData.notes || notes,
597 registrationData.zone_id,
598 registrationData.nameservers ? JSON.stringify(registrationData.nameservers) : null
599 ]
600 );
601
602 // If contract specified, add domain as a line item or update contract
603 if (contract_id) {
604 try {
605 // Optionally create a contract line item for the domain
606 await pool.query(
607 `INSERT INTO contract_line_items (contract_id, description, quantity, unit_price, recurring)
608 VALUES ($1, $2, $3, $4, $5)`,
609 [contract_id, `Domain: ${domain_name}`, 1, renewal_price || 0, true]
610 ).catch(err => {
611 // contract_line_items table might not exist in all setups
612 console.log('Could not add domain to contract line items:', err.message);
613 });
614 } catch (lineItemErr) {
615 console.log('Contract line item creation failed:', lineItemErr);
616 }
617 }
618
619 res.status(201).json(result.rows[0]);
620 } catch (err) {
621 console.error('Error creating domain:', err);
622 res.status(500).json({ error: 'Server error', details: err.message });
623 }
624});
625
626/**
627 * @api {put} /domains/:id Update domain settings
628 * @apiName UpdateDomain
629 * @apiGroup Domains
630 * @apiDescription Updates domain settings including customer association, contract binding,
631 * status, auto-renewal, WHOIS privacy, and pricing information. Can also update DNS
632 * records and nameservers for the domain. Only updates fields that are provided in the
633 * request body, leaving other fields unchanged.
634 *
635 * **Update Capabilities:**
636 * - Customer and contract associations
637 * - Domain status and renewal settings
638 * - Pricing information
639 * - WHOIS privacy settings
640 * - Registrant contact details
641 * - Administrative notes
642 * - DNS nameservers (via Cloudflare API)
643 * @apiHeader {string} Authorization Bearer JWT token with tenant context
644 * @apiHeader {string} Content-Type application/json
645 * @apiParam {number} id Domain ID (URL parameter)
646 * @apiParam {number} [customer_id] Associate domain with customer
647 * @apiParam {number} [contract_id] Associate domain with contract
648 * @apiParam {string} [status] Update domain status
649 * @apiParam {boolean} [auto_renew] Enable/disable auto-renewal
650 * @apiParam {boolean} [whois_privacy] Enable/disable WHOIS privacy
651 * @apiParam {number} [renewal_price] Update renewal price
652 * @apiParam {string} [currency] Price currency (USD, EUR, etc.)
653 * @apiParam {Object} [registrant_contact] Update registrant contact info
654 * @apiParam {string} [notes] Administrative notes
655 * @apiParam {string[]} [nameservers] Update nameservers array
656 * @apiSuccess {Object} domain Updated domain object with all fields
657 * @apiSuccess {string} message Success message
658 * @apiError {string} error Error message
659 * @apiError {number} 400 Invalid parameters
660 * @apiError {number} 404 Domain not found or not accessible
661 * @apiError {Number} 500 Update failed
662 *
663 * @apiExample {curl} Example Request:
664 * curl -X PUT \
665 * -H "Authorization: Bearer eyJhbGc..." \
666 * -H "Content-Type: application/json" \
667 * -d '{"auto_renew": true, "status": "Active"}' \
668 * "https://api.everydaytech.au/domains/123"
669 *
670 * @apiExample {json} Success Response:
671 * HTTP/1.1 200 OK
672 * {
673 * "domain": {
674 * "domain_id": 123,
675 * "domain_name": "example.com",
676 * "auto_renew": true,
677 * "status": "Active"
678 * },
679 * "message": "Domain updated successfully"
680 * }
681 *
682 * @since 2.0.0
683 * @see {@link module:services/cloudflare.updateNameservers} for NS updates
684 */
685router.put('/:id', async (req, res) => {
686 const { id } = req.params;
687 const {
688 customer_id,
689 contract_id,
690 status,
691 auto_renew,
692 whois_privacy,
693 nameservers,
694 dns_records,
695 notes,
696 sync_to_cloudflare = true // Sync DNS changes to Cloudflare
697 } = req.body;
698
699 try {
700 const { clause, params: tenantParams } = getTenantFilter(req, 'd');
701
702 // Check domain exists and user has access
703 let checkQuery = 'SELECT domain_id, domain_name, registrar, registrar_domain_id FROM domains d WHERE domain_id = $1';
704 const checkParams = [id];
705 if (clause) {
706 checkQuery += ` AND ${clause}`;
707 checkParams.push(...tenantParams);
708 }
709
710 const check = await pool.query(checkQuery, checkParams);
711 if (check.rows.length === 0) {
712 return res.status(404).json({ error: 'Domain not found' });
713 }
714
715 const domain = check.rows[0];
716 const updates = [];
717 const values = [];
718 let paramIndex = 1;
719
720 if (customer_id !== undefined) {
721 updates.push(`customer_id = $${paramIndex++}`);
722 values.push(customer_id);
723 }
724 if (contract_id !== undefined) {
725 updates.push(`contract_id = $${paramIndex++}`);
726 values.push(contract_id);
727 }
728 if (status) {
729 updates.push(`status = $${paramIndex++}`);
730 values.push(status);
731 }
732 if (auto_renew !== undefined) {
733 updates.push(`auto_renew = $${paramIndex++}`);
734 values.push(auto_renew);
735 }
736 if (whois_privacy !== undefined) {
737 updates.push(`whois_privacy = $${paramIndex++}`);
738 values.push(whois_privacy);
739 }
740 if (nameservers) {
741 updates.push(`nameservers = $${paramIndex++}`);
742 values.push(JSON.stringify(nameservers));
743 }
744 if (dns_records) {
745 updates.push(`dns_records = $${paramIndex++}`);
746 values.push(JSON.stringify(dns_records));
747 }
748 if (notes !== undefined) {
749 updates.push(`notes = $${paramIndex++}`);
750 values.push(notes);
751 }
752
753 updates.push(`updated_at = NOW()`);
754 values.push(id);
755
756 // Update database first
757 const result = await pool.query(
758 `UPDATE domains SET ${updates.join(', ')} WHERE domain_id = $${paramIndex} RETURNING *`,
759 values
760 );
761
762 // Sync DNS records to Cloudflare if requested and domain is on Cloudflare
763 if (sync_to_cloudflare && domain.registrar === 'cloudflare' && dns_records) {
764 try {
765 let zoneId = domain.registrar_domain_id;
766
767 // Get zone ID if not stored
768 if (!zoneId) {
769 zoneId = await getZoneId(domain.domain_name);
770 if (zoneId) {
771 // Store the zone ID
772 await pool.query(
773 'UPDATE domains SET registrar_domain_id = $1 WHERE domain_id = $2',
774 [zoneId, id]
775 );
776 }
777 }
778
779 if (zoneId) {
780 console.log(`[Domains] Syncing DNS records to Cloudflare for ${domain.domain_name}`);
781
782 // Get existing DNS records from Cloudflare
783 const existingRecords = await listDNSRecords(zoneId);
784 const existingMap = new Map();
785 for (const record of existingRecords) {
786 // Skip Cloudflare-managed records (SOA, NS)
787 if (record.type === 'SOA' || (record.type === 'NS' && record.name === domain.domain_name)) {
788 continue;
789 }
790 const key = `${record.type}:${record.name}:${record.content}`;
791 existingMap.set(key, record);
792 }
793
794 // Parse incoming DNS records
795 const newRecords = typeof dns_records === 'string' ? JSON.parse(dns_records) : dns_records;
796 const newRecordKeys = new Set();
797
798 // Create or update records
799 for (const record of newRecords) {
800 if (!record.type || !record.name || !record.value) {
801 continue;
802 }
803
804 const key = `${record.type}:${record.name}:${record.value}`;
805 newRecordKeys.add(key);
806
807 const existingRecord = existingMap.get(key);
808
809 try {
810 if (existingRecord) {
811 // Update existing record if TTL or priority changed
812 if (existingRecord.ttl !== record.ttl || existingRecord.priority !== record.priority) {
813 await updateDNSRecord(zoneId, existingRecord.id, {
814 type: record.type,
815 name: record.name,
816 content: record.value,
817 ttl: record.ttl || 3600,
818 priority: record.priority,
819 proxied: record.proxied || false
820 });
821 console.log(`[Domains] Updated DNS record: ${record.type} ${record.name}`);
822 }
823 } else {
824 // Create new record
825 await createDNSRecord(zoneId, {
826 type: record.type,
827 name: record.name,
828 content: record.value,
829 ttl: record.ttl || 3600,
830 priority: record.priority,
831 proxied: record.proxied || false
832 });
833 console.log(`[Domains] Created DNS record: ${record.type} ${record.name}`);
834 }
835 } catch (recordErr) {
836 console.warn(`[Domains] Failed to sync DNS record ${record.type} ${record.name}:`, recordErr.message);
837 // Continue with other records
838 }
839 }
840
841 // Delete records that are in Cloudflare but not in the new list
842 for (const [key, record] of existingMap.entries()) {
843 if (!newRecordKeys.has(key)) {
844 try {
845 await deleteDNSRecord(zoneId, record.id);
846 console.log(`[Domains] Deleted DNS record: ${record.type} ${record.name}`);
847 } catch (delErr) {
848 console.warn(`[Domains] Failed to delete DNS record ${record.type} ${record.name}:`, delErr.message);
849 }
850 }
851 }
852
853 console.log(`[Domains] DNS sync completed for ${domain.domain_name}`);
854 } else {
855 console.warn(`[Domains] No Cloudflare zone found for ${domain.domain_name} - skipping DNS sync`);
856 }
857 } catch (syncErr) {
858 console.error(`[Domains] DNS sync failed for ${domain.domain_name}:`, syncErr.message);
859 // Don't fail the whole request if sync fails
860 }
861 }
862
863 res.json({ domain: result.rows[0] });
864 } catch (err) {
865 console.error('Error updating domain:', err);
866 res.status(500).json({ error: 'Server error', details: err.message });
867 }
868});
869
870/**
871 * @api {post} /domains/:id/renew Renew domain registration
872 * @apiName RenewDomain
873 * @apiGroup Domains
874 * @apiDescription Renews a domain registration for specified number of years through the
875 * registrar API. Updates expiration date, records renewal in history, and optionally
876 * charges customer's payment method. Supports eNom and Moniker registrars.
877 *
878 * **Renewal Process:**
879 * 1. Validates domain ownership and tenant access
880 * 2. Calls registrar API to renew domain
881 * 3. Updates expiration date in database
882 * 4. Records renewal in domain_renewals table
883 * 5. Creates invoice if billing enabled
884 * 6. Sends notification to customer
885 * @apiHeader {string} Authorization Bearer JWT token with tenant context
886 * @apiHeader {string} Content-Type application/json
887 * @apiParam {number} id Domain ID (URL parameter)
888 * @apiParam {number} [years=1] Number of years to renew (1-10)
889 * @apiParam {boolean} [charge_customer=true] Charge customer payment method
890 * @apiParam {string} [payment_method] Payment method ID for billing
891 * @apiSuccess {string} message Success message with new expiration date
892 * @apiSuccess {Object} domain Updated domain object
893 * @apiSuccess {Date} domain.expiration_date New expiration date
894 * @apiSuccess {Object} renewal Renewal record details
895 * @apiSuccess {number} renewal.cost Renewal cost charged
896 * @apiSuccess {Date} renewal.renewed_at Renewal timestamp
897 * @apiError {string} error Error message
898 * @apiError {number} 400 Invalid years parameter
899 * @apiError {number} 404 Domain not found
900 * @apiError {number} 402 Payment required but payment method invalid
901 * @apiError {number} 500 Renewal failed at registrar
902 * @apiExample {curl} Example Request:
903 * curl -X POST \
904 * -H "Authorization: Bearer eyJhbGc..." \
905 * -H "Content-Type: application/json" \
906 * -d '{"years": 2}' \
907 * "https://api.everydaytech.au/domains/123/renew"
908 * @apiExample {json} Success Response:
909 * HTTP/1.1 200 OK
910 * {
911 * "message": "Domain renewed successfully until 2028-03-11",
912 * "domain": {
913 * "domain_id": 123,
914 * "expiration_date": "2028-03-11T00:00:00Z"
915 * },
916 * "renewal": {
917 * "cost": 31.98,
918 * "renewed_at": "2026-03-11T10:45:00Z"
919 * }
920 * }
921 * @since 2.0.0
922 * @see {@link module:services/enom.renewDomain} for eNom renewals
923 * @see {@link module:services/moniker.renewDomain} for Moniker renewals
924 */
925router.post('/:id/renew', async (req, res) => {
926 const { id } = req.params;
927 const { years = 1 } = req.body;
928
929 try {
930 const { clause, params: tenantParams } = getTenantFilter(req, 'd');
931
932 // Get domain
933 let query = 'SELECT * FROM domains d WHERE domain_id = $1';
934 const params = [id];
935 if (clause) {
936 query += ` AND ${clause}`;
937 params.push(...tenantParams);
938 }
939
940 const domain = await pool.query(query, params);
941 if (domain.rows.length === 0) {
942 return res.status(404).json({ error: 'Domain not found' });
943 }
944
945 const currentExpiry = new Date(domain.rows[0].expiration_date);
946 const newExpiry = new Date(currentExpiry);
947 newExpiry.setFullYear(newExpiry.getFullYear() + years);
948
949 // TODO: Integrate with registrar API to actually renew the domain
950
951 // Update domain expiration
952 await pool.query(
953 'UPDATE domains SET expiration_date = $1, updated_at = NOW() WHERE domain_id = $2',
954 [newExpiry, id]
955 );
956
957 // Record renewal
958 const renewal = await pool.query(
959 `INSERT INTO domain_renewals (domain_id, renewed_until, amount, currency, status)
960 VALUES ($1, $2, $3, $4, $5) RETURNING *`,
961 [id, newExpiry, domain.rows[0].renewal_price * years, domain.rows[0].currency, 'completed']
962 );
963
964 res.json({
965 message: 'Domain renewed successfully',
966 renewal: renewal.rows[0],
967 new_expiration: newExpiry
968 });
969 } catch (err) {
970 console.error('Error renewing domain:', err);
971 res.status(500).json({ error: 'Server error', details: err.message });
972 }
973});
974
975/**
976 * @api {delete} /domains/:id Delete domain
977 * @apiName DeleteDomain
978 * @apiGroup Domains
979 * @apiDescription Soft-deletes a domain from the system by marking it as deleted.
980 * Does NOT actually cancel or delete the domain at the registrar - only removes
981 * it from the RMM database. Domain can be restored by updating status. Use with
982 * caution as this removes domain from customer view and billing.
983 *
984 * **Important Notes:**
985 * - This is a soft delete (status change, not physical deletion)
986 * - Domain remains at registrar and will continue to renew if auto-renew enabled
987 * - DNS zones and records remain active at Cloudflare
988 * - To fully cancel domain, use registrar's control panel
989 * - Deletion is logged in audit trail
990 * @apiHeader {string} Authorization Bearer JWT token with tenant context
991 * @apiParam {number} id Domain ID (URL parameter)
992 * @apiParam {boolean} [force=false] Force delete even if associated with contracts
993 * @apiSuccess {string} message Success message
994 * @apiSuccess {number} domain_id Deleted domain ID
995 * @apiError {string} error Error message
996 * @apiError {number} 404 Domain not found
997 * @apiError {number} 409 Domain has active dependencies (contracts, etc.)
998 * @apiError {number} 500 Deletion failed
999 * @apiExample {curl} Example Request:
1000 * curl -X DELETE \
1001 * -H "Authorization: Bearer eyJhbGc..." \
1002 * "https://api.everydaytech.au/domains/123"
1003 * @apiExample {json} Success Response:
1004 * HTTP/1.1 200 OK
1005 * {
1006 * "message": "Domain deleted successfully",
1007 * "domain_id": 123
1008 * }
1009 * @since 2.0.0
1010 */
1011router.delete('/:id', async (req, res) => {
1012 const { id } = req.params;
1013
1014 try {
1015 const { clause, params: tenantParams } = getTenantFilter(req, 'd');
1016
1017 // Check domain exists
1018 let query = 'SELECT * FROM domains d WHERE domain_id = $1';
1019 const params = [id];
1020 if (clause) {
1021 query += ` AND ${clause}`;
1022 params.push(...tenantParams);
1023 }
1024
1025 const domain = await pool.query(query, params);
1026 if (domain.rows.length === 0) {
1027 return res.status(404).json({ error: 'Domain not found' });
1028 }
1029
1030 // TODO: Integrate with registrar API to cancel domain if needed
1031
1032 // Mark as cancelled rather than deleting
1033 await pool.query(
1034 'UPDATE domains SET status = $1, updated_at = NOW() WHERE domain_id = $2',
1035 ['cancelled', id]
1036 );
1037
1038 res.json({ message: 'Domain cancelled successfully' });
1039 } catch (err) {
1040 console.error('Error deleting domain:', err);
1041 res.status(500).json({ error: 'Server error', details: err.message });
1042 }
1043});
1044
1045// ========================================
1046// Domain Management Operations
1047// ========================================
1048
1049/**
1050 * @api {post} /domains/:id/lock Enable domain lock
1051 * @apiName LockDomain
1052 * @apiGroup Domains
1053 * @apiDescription Enables registrar lock (transfer lock) on a domain to prevent
1054 * unauthorized transfers. Calls the registrar API (eNom/Moniker) to enable the lock
1055 * and updates the domain's lock status in the database. Locked domains cannot be
1056 * transferred to another registrar without first unlocking.
1057 *
1058 * **Security Feature:**
1059 * - Prevents domain hijacking
1060 * - Blocks unauthorized transfers
1061 * - Required action before domain can be transferred out
1062 * - Recommended for high-value domains
1063 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1064 * @apiParam {number} id Domain ID (URL parameter)
1065 * @apiSuccess {string} message Success message
1066 * @apiSuccess {boolean} locked Always true when successful
1067 * @apiSuccess {object} domain Updated domain object
1068 * @apiError {string} error Error message
1069 * @apiError {number} 404 Domain not found
1070 * @apiError {number} 400 Domain registrar does not support locking
1071 * @apiError {number} 500 Lock operation failed at registrar
1072 * @apiExample {curl} Example Request:
1073 * curl -X POST \
1074 * -H "Authorization: Bearer eyJhbGc..." \
1075 * "https://api.everydaytech.au/domains/123/lock"
1076 * @apiExample {json} Success Response:
1077 * HTTP/1.1 200 OK
1078 * {
1079 * "message": "Domain locked successfully",
1080 * "locked": true
1081 * }
1082 * @since 2.0.0
1083 * @see {@link module:routes/domains.unlockDomain} for unlocking
1084 * @see {@link module:services/enom.lockDomain} for eNom lock API
1085 */
1086router.post('/:id/lock', async (req, res) => {
1087 try {
1088 const { id } = req.params;
1089 const { clause, params } = getTenantFilter(req, 'd');
1090
1091 // Get domain info
1092 const domainResult = await pool.query(
1093 `SELECT * FROM domains d WHERE d.domain_id = $1 ${clause ? 'AND ' + clause : ''}`,
1094 clause ? [id, ...params] : [id]
1095 );
1096
1097 if (domainResult.rows.length === 0) {
1098 return res.status(404).json({ error: 'Domain not found' });
1099 }
1100
1101 const domain = domainResult.rows[0];
1102 const registrar = domain.registrar;
1103 const domainName = domain.domain_name;
1104
1105 let result;
1106 if (registrar === 'moniker') {
1107 const monikerService = require('../services/moniker');
1108 result = await monikerService.lockDomain(domainName);
1109 } else if (registrar === 'enom') {
1110 const enomService = require('../services/enom');
1111 result = await enomService.lockDomain(domainName);
1112 } else if (registrar === 'cloudflare') {
1113 return res.status(400).json({ error: 'Domain lock not supported for Cloudflare domains' });
1114 } else {
1115 return res.status(400).json({ error: 'Unsupported registrar' });
1116 }
1117
1118 // Update database
1119 const metadata = domain.metadata || {};
1120 metadata.locked = true;
1121 await pool.query(
1122 'UPDATE domains SET metadata = $1, updated_at = NOW() WHERE domain_id = $2',
1123 [JSON.stringify(metadata), id]
1124 );
1125
1126 res.json({ message: 'Domain locked successfully', locked: true });
1127 } catch (err) {
1128 console.error('Error locking domain:', err);
1129 res.status(500).json({ error: 'Server error', details: err.message });
1130 }
1131});
1132
1133/**
1134 * @api {post} /domains/:id/unlock Disable domain lock
1135 * @apiName UnlockDomain
1136 * @apiGroup Domains
1137 * @apiDescription Disables registrar lock on a domain to allow transfers. Calls the
1138 * registrar API (eNom/Moniker) to remove the transfer lock. Required before domain
1139 * can be transferred to another registrar. Use with caution as unlocked domains
1140 * are vulnerable to unauthorized transfer attempts.
1141 *
1142 * **Security Warning:**
1143 * - Unlocked domains can be transferred without additional verification
1144 * - Only unlock when actively transferring domain
1145 * - Re-lock immediately after transfer if staying with registrar
1146 * - Monitor domain status while unlocked
1147 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1148 * @apiParam {number} id Domain ID (URL parameter)
1149 * @apiParam {string} [reason] Reason for unlocking (audit trail)
1150 * @apiSuccess {string} message Success message
1151 * @apiSuccess {boolean} locked Always false when successful
1152 * @apiSuccess {object} domain Updated domain object
1153 * @apiError {string} error Error message
1154 * @apiError {number} 404 Domain not found
1155 * @apiError {number} 400 Domain registrar does not support locking
1156 * @apiError {number} 500 Unlock operation failed at registrar
1157 * @apiExample {curl} Example Request:
1158 * curl -X POST \
1159 * -H "Authorization: Bearer eyJhbGc..." \
1160 * -H "Content-Type: application/json" \
1161 * -d '{"reason": "Transferring to new registrar"}' \
1162 * "https://api.everydaytech.au/domains/123/unlock"
1163 * @apiExample {json} Success Response:
1164 * HTTP/1.1 200 OK
1165 * {
1166 * "message": "Domain unlocked successfully",
1167 * "locked": false
1168 * }
1169 * @since 2.0.0
1170 * @see {@link module:routes/domains.lockDomain} for locking
1171 * @see {@link module:services/enom.unlockDomain} for eNom unlock API
1172 */
1173router.post('/:id/unlock', async (req, res) => {
1174 try {
1175/**
1176 * @api {get} /domains/:id/auth-code Get domain authorization code
1177 * @apiName GetAuthCode
1178 * @apiGroup Domains
1179 * @apiDescription Retrieves the EPP authorization code (also called auth code or transfer
1180 * code) for a domain. Required to transfer domain to another registrar. Calls the
1181 * registrar API (eNom/Moniker) to generate or retrieve the code. Code is typically
1182 * valid for 30 days and should be kept confidential.
1183 *
1184 * **Transfer Code Details:**
1185 * - Also called: EPP code, auth code, transfer key
1186 * - Required for domain transfers between registrars
1187 * - Single-use or time-limited validity
1188 * - Should be kept confidential - acts as password for domain
1189 * - Domain must be unlocked before code can be used
1190 *
1191 * **Security:**
1192 * - Only provide to authorized personnel
1193 * - Verify recipient before sharing
1194 * - Generate new code if compromised
1195 * - Monitor domain for unauthorized transfer attempts
1196 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1197 * @apiParam {number} id Domain ID (URL parameter)
1198 * @apiSuccess {string} auth_code EPP authorization code
1199 * @apiSuccess {string} domain_name Domain name
1200 * @apiSuccess {string} message Instructions for using the code
1201 * @apiSuccess {Date} [valid_until] Code expiration date (if available)
1202 * @apiError {string} error Error message
1203 * @apiError {number} 404 Domain not found
1204 * @apiError {number} 423 Domain is locked - unlock before retrieving code
1205 * @apiError {number} 400 Domain registrar does not support auth codes
1206 * @apiError {number} 500 Failed to retrieve auth code from registrar
1207 * @apiExample {curl} Example Request:
1208 * curl -H "Authorization: Bearer eyJhbGc..." \
1209 * "https://api.everydaytech.au/domains/123/auth-code"
1210 * @apiExample {json} Success Response:
1211 * HTTP/1.1 200 OK
1212 * {
1213 * "auth_code": "Xy9#mK2$pL8q",
1214 * "domain_name": "example.com",
1215 * "message": "Provide this code to your new registrar to initiate transfer",
1216 * "valid_until": "2026-04-10T00:00:00Z"
1217 * }
1218 * @apiExample {json} Error Response (Domain Locked):
1219 * HTTP/1.1 423 Locked
1220 * {
1221 * "error": "Domain is locked. Unlock domain before retrieving auth code."
1222 * }
1223 * @since 2.0.0
1224 * @see {@link module:routes/domains.unlockDomain} for unlocking domain
1225 * @see {@link module:services/enom.getAuthCode} for eNom auth code API
1226 */
1227
1228 const { id } = req.params;
1229 const { clause, params } = getTenantFilter(req, 'd');
1230
1231 // Get domain info
1232 const domainResult = await pool.query(
1233 `SELECT * FROM domains d WHERE d.domain_id = $1 ${clause ? 'AND ' + clause : ''}`,
1234 clause ? [id, ...params] : [id]
1235 );
1236
1237 if (domainResult.rows.length === 0) {
1238 return res.status(404).json({ error: 'Domain not found' });
1239 }
1240
1241 const domain = domainResult.rows[0];
1242 const registrar = domain.registrar;
1243 const domainName = domain.domain_name;
1244
1245 let result;
1246 if (registrar === 'moniker') {
1247 const monikerService = require('../services/moniker');
1248 result = await monikerService.unlockDomain(domainName);
1249 } else if (registrar === 'enom') {
1250 const enomService = require('../services/enom');
1251 result = await enomService.unlockDomain(domainName);
1252 } else if (registrar === 'cloudflare') {
1253 return res.status(400).json({ error: 'Domain lock not supported for Cloudflare domains' });
1254 } else {
1255 return res.status(400).json({ error: 'Unsupported registrar' });
1256 }
1257
1258 // Update database
1259 const metadata = domain.metadata || {};
1260 metadata.locked = false;
1261 await pool.query(
1262 'UPDATE domains SET metadata = $1, updated_at = NOW() WHERE domain_id = $2',
1263 [JSON.stringify(metadata), id]
1264 );
1265
1266 res.json({ message: 'Domain unlocked successfully', locked: false });
1267 } catch (err) {
1268 console.error('Error unlocking domain:', err);
1269 res.status(500).json({ error: 'Server error', details: err.message });
1270 }
1271});
1272
1273// GET /domains/:id/auth-code - Get domain transfer/auth code
1274router.get('/:id/auth-code', async (req, res) => {
1275 try {
1276 const { id } = req.params;
1277 const { clause, params } = getTenantFilter(req, 'd');
1278
1279 // Get domain info
1280 const domainResult = await pool.query(
1281 `SELECT * FROM domains d WHERE d.domain_id = $1 ${clause ? 'AND ' + clause : ''}`,
1282 clause ? [id, ...params] : [id]
1283 );
1284
1285 if (domainResult.rows.length === 0) {
1286 return res.status(404).json({ error: 'Domain not found' });
1287 }
1288
1289 const domain = domainResult.rows[0];
1290 const registrar = domain.registrar;
1291 const domainName = domain.domain_name;
1292
1293 let result;
1294 if (registrar === 'moniker') {
1295 const monikerService = require('../services/moniker');
1296 result = await monikerService.getAuthCode(domainName);
1297 } else if (registrar === 'enom') {
1298 const enomService = require('../services/enom');
1299 result = await enomService.getAuthCode(domainName);
1300 } else if (registrar === 'cloudflare') {
1301 return res.status(400).json({ error: 'Auth code not applicable for Cloudflare domains' });
1302 } else {
1303 return res.status(400).json({ error: 'Unsupported registrar' });
1304 }
1305
1306 res.json({
1307 domain: domainName,
1308 auth_code: result.auth_code,
1309 message: 'Auth code retrieved successfully'
1310 });
1311 } catch (err) {
1312 console.error('Error getting auth code:', err);
1313 res.status(500).json({ error: 'Server error', details: err.message });
1314 }
1315});
1316
1317/**
1318 * @api {post} /domains/:id/auto-renew Configure domain auto-renewal
1319 * @apiName SetAutoRenew
1320 * @apiGroup Domains
1321 * @apiDescription Enables or disables automatic renewal for a domain. When enabled,
1322 * the domain will be automatically renewed before expiration if sufficient account
1323 * balance is available. Sends command to the registrar API (eNom/Moniker/Cloudflare)
1324 * and updates the auto-renewal status in the database.
1325 *
1326 * **Auto-Renewal Behavior:**
1327 * - Renewal attempted 30 days before expiration
1328 * - Requires sufficient account credit or valid payment method
1329 * - If renewal fails, notification sent to customer
1330 * - Can be disabled anytime before renewal attempt
1331 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1332 * @apiParam {number} id Domain ID (URL parameter)
1333 * @apiParam {boolean} enabled true to enable auto-renewal, false to disable
1334 * @apiSuccess {string} message Success message
1335 * @apiSuccess {boolean} auto_renew Updated auto-renewal status
1336 * @apiSuccess {object} domain Updated domain object
1337 * @apiError {string} error Error message
1338 * @apiError {number} 400 Invalid enabled value (must be boolean)
1339 * @apiError {number} 404 Domain not found
1340 * @apiError {number} 500 Failed to update auto-renewal status at registrar
1341 * @apiExample {curl} Enable Auto-Renewal:
1342 * curl -X POST \
1343 * -H "Authorization: Bearer eyJhbGc..." \
1344 * -H "Content-Type: application/json" \
1345 * -d '{"enabled": true}' \
1346 * "https://api.everydaytech.au/domains/123/auto-renew"
1347 * @apiExample {json} Success Response:
1348 * HTTP/1.1 200 OK
1349 * {
1350 * "message": "Auto-renewal enabled",
1351 * "auto_renew": true
1352 * }
1353 * @since 2.0.0
1354 * @see {@link module:routes/domains.renewDomain} for manual renewal
1355 */
1356router.post('/:id/auto-renew', async (req, res) => {
1357 try {
1358 const { id } = req.params;
1359 const { enabled } = req.body;
1360 const { clause, params } = getTenantFilter(req, 'd');
1361
1362 if (typeof enabled !== 'boolean') {
1363 return res.status(400).json({ error: 'enabled field must be a boolean' });
1364 }
1365
1366 // Get domain info
1367 const domainResult = await pool.query(
1368 `SELECT * FROM domains d WHERE d.domain_id = $1 ${clause ? 'AND ' + clause : ''}`,
1369 clause ? [id, ...params] : [id]
1370 );
1371
1372 if (domainResult.rows.length === 0) {
1373 return res.status(404).json({ error: 'Domain not found' });
1374 }
1375
1376 const domain = domainResult.rows[0];
1377 const registrar = domain.registrar;
1378 const domainName = domain.domain_name;
1379
1380 let result;
1381 if (registrar === 'moniker') {
1382 const monikerService = require('../services/moniker');
1383 result = await monikerService.setAutoRenew(domainName, enabled);
1384 } else if (registrar === 'enom') {
1385 const enomService = require('../services/enom');
1386 result = await enomService.setAutoRenew(domainName, enabled);
1387 } else if (registrar === 'cloudflare') {
1388 return res.status(400).json({ error: 'Auto-renew not applicable for Cloudflare domains' });
1389 } else {
1390 return res.status(400).json({ error: 'Unsupported registrar' });
1391 }
1392
1393 // Update database
1394 const metadata = domain.metadata || {};
1395 metadata.auto_renew = enabled;
1396 await pool.query(
1397 'UPDATE domains SET metadata = $1, updated_at = NOW() WHERE domain_id = $2',
1398 [JSON.stringify(metadata), id]
1399 );
1400
1401 res.json({
1402 message: `Auto-renew ${enabled ? 'enabled' : 'disabled'} successfully`,
1403 auto_renew: enabled
1404 });
1405 } catch (err) {
1406 console.error('Error setting auto-renew:', err);
1407 res.status(500).json({ error: 'Server error', details: err.message });
1408 }
1409});
1410
1411/**
1412 * @api {get} /domains/:id/contacts Get domain contact information
1413 * @apiName GetDomainContacts
1414 * @apiGroup Domains
1415 * @apiDescription Retrieves WHOIS contact information for a domain including registrant,
1416 * administrative, technical, and billing contacts. Fetches data from the registrar API
1417 * (eNom/Moniker/Cloudflare). Contact details may be hidden if WHOIS privacy is enabled.
1418 *
1419 * **Contact Types:**
1420 * - **Registrant:** Domain owner details
1421 * - **Administrative:** Primary contact for domain management
1422 * - **Technical:** Contact for technical DNS issues
1423 * - **Billing:** Contact for payment and renewal
1424 *
1425 * **Privacy Note:**
1426 * If WHOIS privacy is enabled, real contact details are replaced with privacy service
1427 * information. Disable privacy to view actual contacts.
1428 * @apiHeader {string} Authorization Bearer JWT token with tenant context
1429 * @apiParam {number} id Domain ID (URL parameter)
1430 * @apiSuccess {object} registrant Registrant contact information
1431 * @apiSuccess {object} admin Administrative contact
1432 * @apiSuccess {object} tech Technical contact
1433 * @apiSuccess {object} billing Billing contact
1434 * @apiSuccess {boolean} privacy_enabled Whether WHOIS privacy is active
1435 * @apiError {string} error Error message
1436 * @apiError {number} 404 Domain not found
1437 * @apiError {number} 400 Registrar does not support contact retrieval
1438 * @apiError {number} 500 Failed to fetch contacts from registrar
1439 * @apiExample {curl} Example Request:
1440 * curl -H "Authorization: Bearer eyJhbGc..." \
1441 * "https://api.everydaytech.au/domains/123/contacts"
1442 * @apiExample {json} Success Response:
1443 * HTTP/1.1 200 OK
1444 * {
1445 * "registrant": {
1446 * "name": "John Smith",
1447 * "organization": "Acme Corp",
1448 * "email": "john@example.com",
1449 * "phone": "+1.5555551234",
1450 * "address": "123 Main St",
1451 * "city": "Sydney",
1452 * "state": "NSW",
1453 * "postal_code": "2000",
1454 * "country": "AU"
1455 * },
1456 * "admin": { ...similar structure... },
1457 * "tech": { ...similar structure... },
1458 * "billing": { ...similar structure... },
1459 * "privacy_enabled": false
1460 * }
1461 * @since 2.0.0
1462 * @see {@link module:routes/domains.updateDomain} for updating contacts
1463 */
1464router.get('/:id/contacts', async (req, res) => {
1465 try {
1466 const { id } = req.params;
1467 const { clause, params } = getTenantFilter(req, 'd');
1468
1469 // Get domain info
1470 const domainResult = await pool.query(
1471 `SELECT * FROM domains d WHERE d.domain_id = $1 ${clause ? 'AND ' + clause : ''}`,
1472 clause ? [id, ...params] : [id]
1473 );
1474
1475 if (domainResult.rows.length === 0) {
1476 return res.status(404).json({ error: 'Domain not found' });
1477 }
1478
1479 const domain = domainResult.rows[0];
1480 const registrar = domain.registrar;
1481 const domainName = domain.domain_name;
1482
1483 let result;
1484 if (registrar === 'moniker') {
1485 const monikerService = require('../services/moniker');
1486 result = await monikerService.getContacts(domainName);
1487 } else if (registrar === 'enom') {
1488 const enomService = require('../services/enom');
1489 result = await enomService.getContacts(domainName);
1490 } else if (registrar === 'cloudflare') {
1491 return res.status(400).json({ error: 'Contact information not available for Cloudflare domains' });
1492 } else {
1493 return res.status(400).json({ error: 'Unsupported registrar' });
1494 }
1495
1496 res.json(result);
1497 } catch (err) {
1498 console.error('Error getting contacts:', err);
1499 res.status(500).json({ error: 'Server error', details: err.message });
1500 }
1501});
1502
1503// ========================================
1504// Domain Sync Operations
1505// ========================================
1506
1507/**
1508 * @api {post} /domains/sync Trigger manual domain sync
1509 * @apiName TriggerDomainSync
1510 * @apiGroup Domain Sync
1511 * @apiDescription Manually triggers a background job to sync all domains from all registrars
1512 * (Cloudflare, eNom, Moniker). Fetches domain lists and details from each registrar API,
1513 * updates local database with latest information (expiration dates, renewal prices, lock status),
1514 * and caches results in Redis. Only available to root MSP tenant for administrative purposes.
1515 *
1516 * **Sync Process:**
1517 * 1. Query each registrar API for full domain list
1518 * 2. Fetch detailed information for each domain
1519 * 3. Update database with latest data
1520 * 4. Cache results in Redis for fast access
1521 * 5. Generate sync summary statistics
1522 *
1523 * **Root Tenant Only:**
1524 * Only the root MSP tenant can trigger sync operations to prevent abuse
1525 * and ensure data consistency across all sub-tenants.
1526 * @apiHeader {string} Authorization Bearer JWT token with root tenant context
1527 * @apiSuccess {string} message Sync initiated message
1528 * @apiSuccess {string} status Always "running" when successfully started
1529 * @apiError {string} error Error message
1530 * @apiError {number} 403 Forbidden - Only root tenant can trigger sync
1531 * @apiError {number} 500 Server error during sync initiation
1532 * @apiExample {curl} Example Request:
1533 * curl -X POST \
1534 * -H "Authorization: Bearer eyJhbGc..." \
1535 * "https://api.everydaytech.au/domains/sync"
1536 * @apiExample {json} Success Response:
1537 * HTTP/1.1 200 OK
1538 * {
1539 * "message": "Domain sync started",
1540 * "status": "running"
1541 * }
1542 * @since 2.0.0
1543 * @see {@link module:routes/domains.getSyncStatus} for checking sync progress
1544 * @see {@link module:workers/domainSyncWorker} for sync implementation
1545 */
1546router.post('/sync', async (req, res) => {
1547 try {
1548 // Only allow root tenant to trigger sync
1549 if (!req.tenant || !req.tenant.isMsp) {
1550 return res.status(403).json({ error: 'Only root tenant can trigger domain sync' });
1551 }
1552
1553 const { syncAllDomains } = require('../domainSyncWorker');
1554
1555 // Run sync in background
1556 syncAllDomains().catch(err => {
1557 console.error('[Domain Sync] Error during manual sync:', err);
1558 });
1559
1560 res.json({ message: 'Domain sync started', status: 'running' });
1561 } catch (err) {
1562 console.error('Error triggering domain sync:', err);
1563 res.status(500).json({ error: 'Server error', details: err.message });
1564 }
1565});
1566
1567/**
1568 * @api {get} /domains/sync/status Get domain sync status
1569 * @apiName GetSyncStatus
1570 * @apiGroup Domain Sync
1571 * @apiDescription Retrieves the current status and statistics of the domain sync process.
1572 * Returns last sync timestamp, domain counts per registrar, and total domains synced.
1573 * Data is cached in Redis and updated after each successful sync operation.
1574 *
1575 * **Status Information:**
1576 * - Last successful sync timestamp
1577 * - Domain count per registrar (Cloudflare, eNom, Moniker)
1578 * - Total domains across all registrars
1579 * - No active sync indicator (if never run)
1580 * @apiSuccess {Date} lastSync ISO timestamp of last successful sync
1581 * @apiSuccess {object} registrars Domain counts by registrar
1582 * @apiSuccess {number} registrars.cloudflare.count Cloudflare domain count
1583 * @apiSuccess {number} registrars.moniker.count Moniker domain count
1584 * @apiSuccess {number} registrars.enom.count eNom domain count
1585 * @apiSuccess {number} total Total domains across all registrars
1586 * @apiSuccess {string} [message] Status message if no sync data available
1587 * @apiExample {curl} Example Request:
1588 * curl "https://api.everydaytech.au/domains/sync/status"
1589 * @apiExample {json} Success Response:
1590 * HTTP/1.1 200 OK
1591 * {
1592 * "lastSync": "2026-02-10T14:30:00Z",
1593 * "registrars": {
1594 * "cloudflare": { "count": 245 },
1595 * "moniker": { "count": 89 },
1596 * "enom": { "count": 156 }
1597 * },
1598 * "total": 490
1599 * }
1600 * @apiExample {json} No Sync Data:
1601 * HTTP/1.1 200 OK
1602 * {
1603 * "message": "No sync data available",
1604 * "lastSync": null,
1605 * "registrars": {}
1606 * }
1607 * @since 2.0.0
1608 * @see {@link module:routes/domains.triggerSync} for starting a sync
1609 */
1610router.get('/sync/status', async (req, res) => {
1611 try {
1612 const Redis = require('ioredis');
1613 const redisConfig = require('../config/redis');
1614 const redisClient = new Redis(redisConfig);
1615
1616 // Get sync summary from Redis
1617 const summaryKey = 'domains:sync:summary';
1618 const summary = await redisClient.get(summaryKey);
1619
1620 if (!summary) {
1621 return res.json({
1622 message: 'No sync data available',
1623 lastSync: null,
1624 registrars: {}
1625 });
1626 }
1627
1628 const data = JSON.parse(summary);
1629 await redisClient.quit();
1630
1631 res.json({
1632 lastSync: data.lastSync,
1633 registrars: {
1634 cloudflare: { count: data.cloudflare?.count || 0 },
1635 moniker: { count: data.moniker?.count || 0 },
1636 enom: { count: data.enom?.count || 0 }
1637 },
1638 total: data.total || 0
1639 });
1640 } catch (err) {
1641 console.error('Error getting sync status:', err);
1642 res.status(500).json({ error: 'Server error', details: err.message });
1643 }
1644});
1645
1646/**
1647 * @api {get} /domains/sync/cached Get all cached domain data
1648 * @apiName GetCachedDomains
1649 * @apiGroup Domain Sync
1650 * @apiDescription Retrieves all cached domain data from Redis for all registrars.
1651 * Returns raw domain lists from Cloudflare, eNom, and Moniker without filtering
1652 * or processing. Useful for debugging sync issues or verifying registrar data.
1653 * Cache is updated after each sync operation.
1654 *
1655 * **Data Source:**
1656 * - Returns raw cached data from Redis
1657 * - No database queries - fast response
1658 * - Data freshness depends on last sync time
1659 * - Check sync/status endpoint for last update time
1660 * @apiSuccess {Array} cloudflare Cached Cloudflare domain list
1661 * @apiSuccess {Array} moniker Cached Moniker domain list
1662 * @apiSuccess {Array} enom Cached eNom domain list
1663 * @apiSuccess {number} total Total domains across all caches
1664 * @apiExample {curl} Example Request:
1665 * curl "https://api.everydaytech.au/domains/sync/cached"
1666 * @apiExample {json} Success Response:
1667 * HTTP/1.1 200 OK
1668 * {
1669 * "cloudflare": [
1670 * { "name": "example.com", "expires_on": "2027-01-15", ... },
1671 * { "name": "demo.net", "expires_on": "2026-08-22", ... }
1672 * ],
1673 * "moniker": [...],
1674 * "enom": [...],
1675 * "total": 490
1676 * }
1677 * @since 2.0.0
1678 * @see {@link module:routes/domains.getCachedDomainsByRegistrar} for single registrar
1679 * @see {@link module:routes/domains.getSyncStatus} for cache freshness
1680 */
1681router.get('/sync/cached', async (req, res) => {
1682 try {
1683 const Redis = require('ioredis');
1684 const redisConfig = require('../config/redis');
1685 const redisClient = new Redis(redisConfig);
1686
1687 // Return all cached domains
1688 const [cloudflare, moniker, enom] = await Promise.all([
1689 redisClient.get('domains:sync:cloudflare'),
1690 redisClient.get('domains:sync:moniker'),
1691 redisClient.get('domains:sync:enom')
1692 ]);
1693 await redisClient.quit();
1694
1695 res.json({
1696 cloudflare: cloudflare ? JSON.parse(cloudflare) : [],
1697 moniker: moniker ? JSON.parse(moniker) : [],
1698 enom: enom ? JSON.parse(enom) : []
1699 });
1700 } catch (err) {
1701 console.error('Error getting cached domains:', err);
1702 res.status(500).json({ error: 'Server error', details: err.message });
1703 }
1704});
1705
1706/**
1707 * @api {get} /domains/sync/cached/:registrar Get cached domains by registrar
1708 * @apiName GetCachedDomainsByRegistrar
1709 * @apiGroup Domain Sync
1710 * @apiDescription Retrieves cached domain data from Redis for a specific registrar.
1711 * Returns raw domain list from the specified registrar (cloudflare, moniker, or enom)
1712 * without filtering. Useful for debugging registrar-specific sync issues or verifying
1713 * data from a single source.
1714 *
1715 * **Valid Registrars:**
1716 * - cloudflare: Cloudflare Registrar domains
1717 * - moniker: Moniker domains
1718 * - enom: eNom domains
1719 * @apiParam {string} registrar Registrar name (URL parameter) - must be: cloudflare, moniker, or enom
1720 * @apiSuccess {Array} domains Cached domain list for specified registrar
1721 * @apiSuccess {string} registrar Registrar name
1722 * @apiSuccess {number} count Number of domains in cache
1723 * @apiError {string} error Error message
1724 * @apiError {Array} valid List of valid registrar values
1725 * @apiError {number} 400 Invalid registrar parameter
1726 * @apiError {number} 404 No cached data found for registrar
1727 * @apiExample {curl} Example Request:
1728 * curl "https://api.everydaytech.au/domains/sync/cached/cloudflare"
1729 * @apiExample {json} Success Response:
1730 * HTTP/1.1 200 OK
1731 * {
1732 * "registrar": "cloudflare",
1733 * "domains": [
1734 * {
1735 * "name": "example.com",
1736 * "expires_on": "2027-01-15T00:00:00Z",
1737 * "locked": true,
1738 * "auto_renew": true
1739 * },
1740 * { ... }
1741 * ],
1742 * "count": 245
1743 * }
1744 * @apiExample {json} Error Response (Invalid Registrar):
1745 * HTTP/1.1 400 Bad Request
1746 * {
1747 * "error": "Invalid registrar",
1748 * "valid": ["cloudflare", "moniker", "enom"]
1749 * }
1750 * @since 2.0.0
1751 * @see {@link module:routes/domains.getCachedDomains} for all registrars
1752 */
1753router.get('/sync/cached/:registrar', async (req, res) => {
1754 try {
1755 const { registrar } = req.params;
1756 const validRegistrars = ['cloudflare', 'moniker', 'enom'];
1757
1758 if (!validRegistrars.includes(registrar)) {
1759 return res.status(400).json({
1760 error: 'Invalid registrar',
1761 valid: validRegistrars
1762 });
1763 }
1764
1765 const Redis = require('ioredis');
1766 const redisConfig = require('../config/redis');
1767 const redisClient = new Redis(redisConfig);
1768
1769 const cacheKey = `domains:sync:${registrar}`;
1770 const cached = await redisClient.get(cacheKey);
1771 await redisClient.quit();
1772
1773 if (!cached) {
1774 return res.json({
1775 registrar,
1776 domains: [],
1777 message: 'No cached data available'
1778 });
1779 }
1780
1781 res.json({
1782 registrar,
1783 domains: JSON.parse(cached)
1784 });
1785 } catch (err) {
1786 console.error('Error getting cached domains:', err);
1787 res.status(500).json({ error: 'Server error', details: err.message });
1788 }
1789});
1790
1791module.exports = router;