Add token compensation support for disconnected clients
Browse files- API_DOCUMENTATION.md +52 -0
- src/routes/sessions.routes.js +106 -0
- src/utils/migrate.js +23 -0
API_DOCUMENTATION.md
CHANGED
|
@@ -699,6 +699,58 @@ The token must still belong to the authenticated tenant, still be valid, and sti
|
|
| 699 |
|
| 700 |
---
|
| 701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
### GET `/api/sessions`
|
| 703 |
Session history (active and ended).
|
| 704 |
|
|
|
|
| 699 |
|
| 700 |
---
|
| 701 |
|
| 702 |
+
### POST `/api/sessions/paid-disconnected/:tokenId/compensate`
|
| 703 |
+
Add free compensation time to an affected paid client by extending the original purchased token.
|
| 704 |
+
|
| 705 |
+
This is a goodwill extension only:
|
| 706 |
+
- no new payment is created
|
| 707 |
+
- revenue, balance, payouts, and ledgers are unchanged
|
| 708 |
+
- the original token code stays the same
|
| 709 |
+
- an audit record is written server-side
|
| 710 |
+
|
| 711 |
+
The token must belong to the authenticated tenant and still be attached to a device in `offline`, `pending`, or `adopting`.
|
| 712 |
+
|
| 713 |
+
**Auth:** Required
|
| 714 |
+
|
| 715 |
+
**Body:**
|
| 716 |
+
```json
|
| 717 |
+
{
|
| 718 |
+
"minutes": 120,
|
| 719 |
+
"reason": "ISP outage compensation"
|
| 720 |
+
}
|
| 721 |
+
```
|
| 722 |
+
|
| 723 |
+
**Validation:**
|
| 724 |
+
- `minutes` must be a positive integer
|
| 725 |
+
- max `minutes` is `10080` (7 days)
|
| 726 |
+
- `reason` optional, max 255 characters
|
| 727 |
+
|
| 728 |
+
**Response `200`:**
|
| 729 |
+
```json
|
| 730 |
+
{
|
| 731 |
+
"success": true,
|
| 732 |
+
"token_id": 88,
|
| 733 |
+
"token_code": "ABCD-1234",
|
| 734 |
+
"device_id": 3,
|
| 735 |
+
"device_name": "Main Building",
|
| 736 |
+
"status": "active",
|
| 737 |
+
"granted_minutes": 120,
|
| 738 |
+
"granted_seconds": 7200,
|
| 739 |
+
"old_expires_at": "2026-05-10T17:35:00.000Z",
|
| 740 |
+
"new_expires_at": "2026-05-10T19:35:00.000Z",
|
| 741 |
+
"remaining_seconds": 7200,
|
| 742 |
+
"reason": "ISP outage compensation",
|
| 743 |
+
"message": "Compensation time added successfully"
|
| 744 |
+
}
|
| 745 |
+
```
|
| 746 |
+
|
| 747 |
+
**Notes:**
|
| 748 |
+
- Use this from the Sessions page for outage support.
|
| 749 |
+
- After success, refresh `GET /api/sessions/paid-disconnected`.
|
| 750 |
+
- If the device is still offline, the row will remain in the list with a longer time left.
|
| 751 |
+
|
| 752 |
+
---
|
| 753 |
+
|
| 754 |
### GET `/api/sessions`
|
| 755 |
Session history (active and ended).
|
| 756 |
|
src/routes/sessions.routes.js
CHANGED
|
@@ -5,6 +5,10 @@ const { requireAuth } = require('../middleware/auth');
|
|
| 5 |
|
| 6 |
router.use(requireAuth);
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
// GET /api/sessions/active
|
| 9 |
router.get('/active', async (req, res) => {
|
| 10 |
try {
|
|
@@ -140,6 +144,108 @@ router.post('/paid-disconnected/:tokenId/message', async (req, res) => {
|
|
| 140 |
}
|
| 141 |
});
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
// GET /api/sessions?deviceId=&limit=
|
| 144 |
router.get('/', async (req, res) => {
|
| 145 |
const { deviceId, limit = 50 } = req.query;
|
|
|
|
| 5 |
|
| 6 |
router.use(requireAuth);
|
| 7 |
|
| 8 |
+
function formatDateTimeLocal(date) {
|
| 9 |
+
return new Date(date).toISOString().slice(0, 19) + 'Z';
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
// GET /api/sessions/active
|
| 13 |
router.get('/active', async (req, res) => {
|
| 14 |
try {
|
|
|
|
| 144 |
}
|
| 145 |
});
|
| 146 |
|
| 147 |
+
// POST /api/sessions/paid-disconnected/:tokenId/compensate
|
| 148 |
+
router.post('/paid-disconnected/:tokenId/compensate', async (req, res) => {
|
| 149 |
+
const minutes = parseInt(req.body.minutes, 10);
|
| 150 |
+
const reason = req.body.reason ? String(req.body.reason).trim() : null;
|
| 151 |
+
|
| 152 |
+
if (!Number.isInteger(minutes) || minutes <= 0) {
|
| 153 |
+
return res.status(400).json({ error: 'minutes must be a positive integer' });
|
| 154 |
+
}
|
| 155 |
+
if (minutes > 7 * 24 * 60) {
|
| 156 |
+
return res.status(400).json({ error: 'minutes too large (max 10080)' });
|
| 157 |
+
}
|
| 158 |
+
if (reason && reason.length > 255) {
|
| 159 |
+
return res.status(400).json({ error: 'reason too long (max 255 characters)' });
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const grantedSeconds = minutes * 60;
|
| 163 |
+
const conn = await db.pool.getConnection();
|
| 164 |
+
|
| 165 |
+
try {
|
| 166 |
+
await conn.beginTransaction();
|
| 167 |
+
|
| 168 |
+
const [rows] = await conn.execute(
|
| 169 |
+
`SELECT t.id AS token_id, t.payment_id, t.client_id, t.device_id, t.code AS token_code,
|
| 170 |
+
t.status, t.expires_at, t.duration_seconds,
|
| 171 |
+
d.name AS device_name, d.status AS device_status, d.billing_status
|
| 172 |
+
FROM access_tokens t
|
| 173 |
+
JOIN devices d ON d.id = t.device_id
|
| 174 |
+
JOIN payments p ON p.id = t.payment_id AND p.status = 'completed'
|
| 175 |
+
WHERE t.id = ?
|
| 176 |
+
AND t.client_id = ?
|
| 177 |
+
AND t.payment_id IS NOT NULL
|
| 178 |
+
AND d.billing_status NOT IN ('suspended', 'cancelled')
|
| 179 |
+
AND d.status IN ('offline', 'pending', 'adopting')
|
| 180 |
+
LIMIT 1`,
|
| 181 |
+
[req.params.tokenId, req.client.id]
|
| 182 |
+
);
|
| 183 |
+
|
| 184 |
+
const token = rows[0];
|
| 185 |
+
if (!token) {
|
| 186 |
+
await conn.rollback();
|
| 187 |
+
return res.status(404).json({ error: 'Compensatable client token not found' });
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const oldExpiry = token.expires_at ? new Date(token.expires_at) : new Date();
|
| 191 |
+
const baseExpiry = oldExpiry > new Date() ? oldExpiry : new Date();
|
| 192 |
+
const newExpiry = new Date(baseExpiry.getTime() + grantedSeconds * 1000);
|
| 193 |
+
|
| 194 |
+
const nextStatus = newExpiry > new Date() ? 'active' : token.status;
|
| 195 |
+
const oldDurationSeconds = Number(token.duration_seconds || 0);
|
| 196 |
+
const nextDurationSeconds = oldDurationSeconds + grantedSeconds;
|
| 197 |
+
|
| 198 |
+
await conn.execute(
|
| 199 |
+
`UPDATE access_tokens
|
| 200 |
+
SET expires_at = ?, duration_seconds = ?, status = ?
|
| 201 |
+
WHERE id = ?`,
|
| 202 |
+
[newExpiry, nextDurationSeconds, nextStatus, token.token_id]
|
| 203 |
+
);
|
| 204 |
+
|
| 205 |
+
await conn.execute(
|
| 206 |
+
`INSERT INTO token_compensations
|
| 207 |
+
(token_id, payment_id, client_id, device_id, actor_type, actor_client_id,
|
| 208 |
+
granted_seconds, old_expires_at, new_expires_at, reason)
|
| 209 |
+
VALUES (?, ?, ?, ?, 'tenant', ?, ?, ?, ?, ?)`,
|
| 210 |
+
[
|
| 211 |
+
token.token_id,
|
| 212 |
+
token.payment_id,
|
| 213 |
+
token.client_id,
|
| 214 |
+
token.device_id,
|
| 215 |
+
req.client.id,
|
| 216 |
+
grantedSeconds,
|
| 217 |
+
token.expires_at || null,
|
| 218 |
+
newExpiry,
|
| 219 |
+
reason,
|
| 220 |
+
]
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
await conn.commit();
|
| 224 |
+
|
| 225 |
+
res.json({
|
| 226 |
+
success: true,
|
| 227 |
+
token_id: token.token_id,
|
| 228 |
+
token_code: token.token_code,
|
| 229 |
+
device_id: token.device_id,
|
| 230 |
+
device_name: token.device_name,
|
| 231 |
+
status: nextStatus,
|
| 232 |
+
granted_minutes: minutes,
|
| 233 |
+
granted_seconds: grantedSeconds,
|
| 234 |
+
old_expires_at: token.expires_at ? formatDateTimeLocal(token.expires_at) : null,
|
| 235 |
+
new_expires_at: formatDateTimeLocal(newExpiry),
|
| 236 |
+
remaining_seconds: Math.max(0, Math.floor((newExpiry.getTime() - Date.now()) / 1000)),
|
| 237 |
+
reason,
|
| 238 |
+
message: 'Compensation time added successfully',
|
| 239 |
+
});
|
| 240 |
+
} catch (err) {
|
| 241 |
+
await conn.rollback();
|
| 242 |
+
console.error('[sessions/paid-disconnected/compensate]', err.message);
|
| 243 |
+
res.status(500).json({ error: 'Failed to compensate client token' });
|
| 244 |
+
} finally {
|
| 245 |
+
conn.release();
|
| 246 |
+
}
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
// GET /api/sessions?deviceId=&limit=
|
| 250 |
router.get('/', async (req, res) => {
|
| 251 |
const { deviceId, limit = 50 } = req.query;
|
src/utils/migrate.js
CHANGED
|
@@ -35,6 +35,29 @@ const migrations = [
|
|
| 35 |
// 005 — Fix any invalid payments.status values before modifying the ENUM
|
| 36 |
`UPDATE payments SET status = 'pending' WHERE status NOT IN ('pending','completed','failed','refunded')`,
|
| 37 |
`ALTER TABLE payments MODIFY COLUMN status ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending'`,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
];
|
| 39 |
|
| 40 |
async function runMigrations() {
|
|
|
|
| 35 |
// 005 — Fix any invalid payments.status values before modifying the ENUM
|
| 36 |
`UPDATE payments SET status = 'pending' WHERE status NOT IN ('pending','completed','failed','refunded')`,
|
| 37 |
`ALTER TABLE payments MODIFY COLUMN status ENUM('pending','completed','failed','refunded') NOT NULL DEFAULT 'pending'`,
|
| 38 |
+
|
| 39 |
+
// 006 — Audit trail for tenant/admin customer compensation extensions
|
| 40 |
+
`CREATE TABLE IF NOT EXISTS token_compensations (
|
| 41 |
+
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
| 42 |
+
token_id BIGINT UNSIGNED NOT NULL,
|
| 43 |
+
payment_id BIGINT UNSIGNED NOT NULL,
|
| 44 |
+
client_id INT UNSIGNED NOT NULL,
|
| 45 |
+
device_id INT UNSIGNED NOT NULL,
|
| 46 |
+
actor_type ENUM('tenant','admin') NOT NULL,
|
| 47 |
+
actor_client_id INT UNSIGNED DEFAULT NULL,
|
| 48 |
+
granted_seconds INT UNSIGNED NOT NULL,
|
| 49 |
+
old_expires_at TIMESTAMP NULL DEFAULT NULL,
|
| 50 |
+
new_expires_at TIMESTAMP NOT NULL,
|
| 51 |
+
reason VARCHAR(255) DEFAULT NULL,
|
| 52 |
+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
| 53 |
+
KEY idx_token_comp_token (token_id),
|
| 54 |
+
KEY idx_token_comp_client (client_id),
|
| 55 |
+
KEY idx_token_comp_payment (payment_id),
|
| 56 |
+
CONSTRAINT fk_token_comp_token FOREIGN KEY (token_id) REFERENCES access_tokens(id) ON DELETE CASCADE,
|
| 57 |
+
CONSTRAINT fk_token_comp_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
|
| 58 |
+
CONSTRAINT fk_token_comp_client FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE CASCADE,
|
| 59 |
+
CONSTRAINT fk_token_comp_device FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE
|
| 60 |
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
| 61 |
];
|
| 62 |
|
| 63 |
async function runMigrations() {
|