orbitx-core-models 11.174.0 → 11.176.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/helpers/_http.d.ts +10 -0
- package/dist/helpers/_http.d.ts.map +1 -0
- package/dist/helpers/_http.js +16 -0
- package/dist/helpers/addonState.d.ts +20 -0
- package/dist/helpers/addonState.d.ts.map +1 -0
- package/dist/helpers/addonState.js +77 -0
- package/dist/helpers/analyticsBuckets.d.ts +32 -0
- package/dist/helpers/analyticsBuckets.d.ts.map +1 -0
- package/dist/helpers/analyticsBuckets.js +143 -0
- package/dist/helpers/avatars.d.ts +15 -0
- package/dist/helpers/avatars.d.ts.map +1 -0
- package/dist/helpers/avatars.js +122 -0
- package/dist/helpers/chatAttachments.d.ts +33 -0
- package/dist/helpers/chatAttachments.d.ts.map +1 -0
- package/dist/helpers/chatAttachments.js +81 -0
- package/dist/helpers/contactDecrypt.d.ts +26 -0
- package/dist/helpers/contactDecrypt.d.ts.map +1 -0
- package/dist/helpers/contactDecrypt.js +95 -0
- package/dist/helpers/emailTemplates.d.ts +19 -0
- package/dist/helpers/emailTemplates.d.ts.map +1 -0
- package/dist/helpers/emailTemplates.js +276 -0
- package/dist/helpers/errors.d.ts +27 -0
- package/dist/helpers/errors.d.ts.map +1 -0
- package/dist/helpers/errors.js +101 -0
- package/dist/helpers/invoiceDocument.d.ts +63 -0
- package/dist/helpers/invoiceDocument.d.ts.map +1 -0
- package/dist/helpers/invoiceDocument.js +148 -0
- package/dist/helpers/listQuery.d.ts +35 -0
- package/dist/helpers/listQuery.d.ts.map +1 -0
- package/dist/helpers/listQuery.js +180 -0
- package/dist/helpers/myUpdates.d.ts +16 -0
- package/dist/helpers/myUpdates.d.ts.map +1 -0
- package/dist/helpers/myUpdates.js +94 -0
- package/dist/helpers/ownActivity.d.ts +8 -0
- package/dist/helpers/ownActivity.d.ts.map +1 -0
- package/dist/helpers/ownActivity.js +114 -0
- package/dist/helpers/ownTickets.d.ts +21 -0
- package/dist/helpers/ownTickets.d.ts.map +1 -0
- package/dist/helpers/ownTickets.js +169 -0
- package/dist/helpers/s3Storage.d.ts +38 -0
- package/dist/helpers/s3Storage.d.ts.map +1 -0
- package/dist/helpers/s3Storage.js +73 -0
- package/dist/helpers/sessions.d.ts +22 -0
- package/dist/helpers/sessions.d.ts.map +1 -0
- package/dist/helpers/sessions.js +61 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -0
- package/package.json +1 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CLIENT_VISIBLE_INVOICE_STATUSES = exports.INVOICE_ISSUER = void 0;
|
|
4
|
+
exports.nextInvoiceChargeDate = nextInvoiceChargeDate;
|
|
5
|
+
exports.createInvoiceDocuments = createInvoiceDocuments;
|
|
6
|
+
/**
|
|
7
|
+
* The invoice as a document: what the invoice page in Ops and the Client dashboard shows (and what prints as the
|
|
8
|
+
* PDF), plus invoice numbers. Shared by the Ops and Client backends; each passes its Stripe client:
|
|
9
|
+
* const invoices = createInvoiceDocuments({ stripe });
|
|
10
|
+
* invoices.buildInvoiceDocument(invoiceId) // Ops: any invoice
|
|
11
|
+
* invoices.buildInvoiceDocument(invoiceId, { companyId, clientView: true }) // Client: its own, past approval
|
|
12
|
+
*/
|
|
13
|
+
const company_model_1 = require("../models/company.model");
|
|
14
|
+
const counter_model_1 = require("../models/counter.model");
|
|
15
|
+
const invoice_model_1 = require("../models/invoice.model");
|
|
16
|
+
const leadLedger_model_1 = require("../models/leadLedger.model");
|
|
17
|
+
const errors_1 = require("./errors");
|
|
18
|
+
/** Who the invoice is from (owner, 2026-09-29). */
|
|
19
|
+
exports.INVOICE_ISSUER = {
|
|
20
|
+
name: "ORBITXPRO LLC",
|
|
21
|
+
addressLines: ["4926 Winding View Ln", "Humble, TX 77346"],
|
|
22
|
+
email: "billing@orbitxpro.com",
|
|
23
|
+
};
|
|
24
|
+
/** The statuses a client can see; drafts and invoices waiting for approval stay inside Ops. */
|
|
25
|
+
exports.CLIENT_VISIBLE_INVOICE_STATUSES = ["approved", "pending_payment", "paid", "void", "disputed"];
|
|
26
|
+
/** Approved invoices are charged together on the 5th of the month. */
|
|
27
|
+
function nextInvoiceChargeDate(from = new Date()) {
|
|
28
|
+
const date = new Date(from.getFullYear(), from.getMonth(), 5, 12, 0, 0);
|
|
29
|
+
if (from.getDate() > 5)
|
|
30
|
+
date.setMonth(date.getMonth() + 1);
|
|
31
|
+
return date;
|
|
32
|
+
}
|
|
33
|
+
function createInvoiceDocuments({ stripe }) {
|
|
34
|
+
const InvoiceModel = invoice_model_1.Invoice;
|
|
35
|
+
const CompanyModel = company_model_1.Company;
|
|
36
|
+
const CounterModel = counter_model_1.Counter;
|
|
37
|
+
const LeadLedgerModel = leadLedger_model_1.LeadLedger;
|
|
38
|
+
/** "INV-2026-0001": sequential across OrbitXPro, starting again each year. */
|
|
39
|
+
async function nextInvoiceNumber(at = new Date()) {
|
|
40
|
+
const year = at.getFullYear();
|
|
41
|
+
const counter = await CounterModel.findByIdAndUpdate(`invoiceNumber_${year}`, { $inc: { seq: 1 } }, { new: true, upsert: true });
|
|
42
|
+
return `INV-${year}-${String(counter.seq).padStart(4, "0")}`;
|
|
43
|
+
}
|
|
44
|
+
/** Invoices made before numbering get theirs the first time they're shown. */
|
|
45
|
+
async function ensureInvoiceNumber(invoice) {
|
|
46
|
+
if (invoice.invoiceNumber)
|
|
47
|
+
return invoice.invoiceNumber;
|
|
48
|
+
const number = await nextInvoiceNumber(new Date(invoice.createdAt || Date.now()));
|
|
49
|
+
const updated = await InvoiceModel.findOneAndUpdate({ _id: invoice._id, invoiceNumber: { $exists: false } }, { $set: { invoiceNumber: number } }, { new: true }).lean();
|
|
50
|
+
if (updated === null || updated === void 0 ? void 0 : updated.invoiceNumber)
|
|
51
|
+
return updated.invoiceNumber;
|
|
52
|
+
const current = await InvoiceModel.findById(invoice._id).select("invoiceNumber").lean();
|
|
53
|
+
return (current === null || current === void 0 ? void 0 : current.invoiceNumber) || number;
|
|
54
|
+
}
|
|
55
|
+
async function cardFor(invoice, company) {
|
|
56
|
+
var _a, _b, _c, _d;
|
|
57
|
+
try {
|
|
58
|
+
if (invoice.stripeInvoiceId) {
|
|
59
|
+
const si = await stripe.invoices.retrieve(invoice.stripeInvoiceId, { expand: ["payment_intent.payment_method"] });
|
|
60
|
+
const card = (_b = (_a = si === null || si === void 0 ? void 0 : si.payment_intent) === null || _a === void 0 ? void 0 : _a.payment_method) === null || _b === void 0 ? void 0 : _b.card;
|
|
61
|
+
if (card)
|
|
62
|
+
return { brand: card.brand, last4: card.last4 };
|
|
63
|
+
}
|
|
64
|
+
if (company === null || company === void 0 ? void 0 : company.stripeCustomerId) {
|
|
65
|
+
const customer = await stripe.customers.retrieve(company.stripeCustomerId, { expand: ["invoice_settings.default_payment_method"] });
|
|
66
|
+
const card = (_d = (_c = customer === null || customer === void 0 ? void 0 : customer.invoice_settings) === null || _c === void 0 ? void 0 : _c.default_payment_method) === null || _d === void 0 ? void 0 : _d.card;
|
|
67
|
+
if (card)
|
|
68
|
+
return { brand: card.brand, last4: card.last4 };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
console.error(`[invoice] card for ${invoice._id} not read:`, err.message);
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Everything the invoice page shows. `companyId` limits it to that company's own invoices; `clientView` also
|
|
78
|
+
* hides drafts/unapproved invoices and the Stripe link (Client dashboard).
|
|
79
|
+
*/
|
|
80
|
+
async function buildInvoiceDocument(invoiceId, { companyId, clientView = false } = {}) {
|
|
81
|
+
var _a, _b;
|
|
82
|
+
const invoice = clientView
|
|
83
|
+
? await InvoiceModel.findOne({ _id: invoiceId, companyId, status: { $in: exports.CLIENT_VISIBLE_INVOICE_STATUSES } }).lean().catch(() => null)
|
|
84
|
+
: await InvoiceModel.findById(invoiceId).lean().catch(() => null);
|
|
85
|
+
if (!invoice || (companyId && String(invoice.companyId) !== String(companyId)))
|
|
86
|
+
throw new errors_1.AppError("Invoice not found", 404);
|
|
87
|
+
const [company, number, entries] = await Promise.all([
|
|
88
|
+
CompanyModel.findById(invoice.companyId).select("contactInfo basicInfo.companyId stripeCustomerId").lean(),
|
|
89
|
+
ensureInvoiceNumber(invoice),
|
|
90
|
+
LeadLedgerModel.find({ invoiceId: invoice._id, status: { $ne: "discarded" } })
|
|
91
|
+
.sort({ createdAt: 1 })
|
|
92
|
+
.populate({ path: "leadId", select: "chatId data.name", populate: { path: "chatId", select: "chatId chatNumber" } })
|
|
93
|
+
.lean(),
|
|
94
|
+
]);
|
|
95
|
+
const card = await cardFor(invoice, company);
|
|
96
|
+
const contact = (company === null || company === void 0 ? void 0 : company.contactInfo) || {};
|
|
97
|
+
const items = entries.map((e) => {
|
|
98
|
+
var _a, _b, _c;
|
|
99
|
+
const quantity = e.quantity || 1;
|
|
100
|
+
const sign = e.type === "deduct" ? -1 : 1;
|
|
101
|
+
let description;
|
|
102
|
+
if (e.type === "lead") {
|
|
103
|
+
const chat = (_a = e.leadId) === null || _a === void 0 ? void 0 : _a.chatId;
|
|
104
|
+
const ref = (chat === null || chat === void 0 ? void 0 : chat.chatId) || ((chat === null || chat === void 0 ? void 0 : chat.chatNumber) != null ? String(chat.chatNumber) : "");
|
|
105
|
+
const name = (_c = (_b = e.leadId) === null || _b === void 0 ? void 0 : _b.data) === null || _c === void 0 ? void 0 : _c.name;
|
|
106
|
+
description = `Lead${ref ? ` ${ref}` : ""}${name ? ` · ${name}` : ""}`;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
description = e.note || (e.type === "add" ? "Additional charge" : "Credit");
|
|
110
|
+
}
|
|
111
|
+
return { date: e.createdAt, type: e.type, description, quantity, unitPrice: e.unitPrice, amount: sign * e.unitPrice * quantity };
|
|
112
|
+
});
|
|
113
|
+
const chargeStatuses = clientView ? ["approved"] : ["draft", "pending_approval", "approved"];
|
|
114
|
+
return {
|
|
115
|
+
invoice: {
|
|
116
|
+
_id: invoice._id,
|
|
117
|
+
number,
|
|
118
|
+
status: invoice.status,
|
|
119
|
+
issueDate: invoice.createdAt,
|
|
120
|
+
periodStart: invoice.billingPeriodStart,
|
|
121
|
+
periodEnd: invoice.billingPeriodEnd,
|
|
122
|
+
subtotal: invoice.subtotal,
|
|
123
|
+
adjustments: invoice.adjustments,
|
|
124
|
+
totalDue: invoice.totalDue,
|
|
125
|
+
approvedAt: invoice.approvedAt || null,
|
|
126
|
+
paidAt: invoice.paidAt || null,
|
|
127
|
+
chargeDate: chargeStatuses.includes(invoice.status)
|
|
128
|
+
? nextInvoiceChargeDate(invoice.approvedAt ? new Date(invoice.approvedAt) : new Date())
|
|
129
|
+
: null,
|
|
130
|
+
stripeInvoiceUrl: clientView ? null : invoice.stripeInvoiceUrl || null,
|
|
131
|
+
},
|
|
132
|
+
from: exports.INVOICE_ISSUER,
|
|
133
|
+
billTo: {
|
|
134
|
+
name: contact.officialBusinessName || ((_a = company === null || company === void 0 ? void 0 : company.basicInfo) === null || _a === void 0 ? void 0 : _a.companyId) || "",
|
|
135
|
+
companyId: ((_b = company === null || company === void 0 ? void 0 : company.basicInfo) === null || _b === void 0 ? void 0 : _b.companyId) || "",
|
|
136
|
+
address: contact.address || "",
|
|
137
|
+
city: contact.city || "",
|
|
138
|
+
state: contact.state || "",
|
|
139
|
+
zip: contact.zipcode || "",
|
|
140
|
+
country: contact.country || "",
|
|
141
|
+
email: contact.email || "",
|
|
142
|
+
},
|
|
143
|
+
items,
|
|
144
|
+
card,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return { ISSUER: exports.INVOICE_ISSUER, nextInvoiceNumber, ensureInvoiceNumber, nextChargeDate: nextInvoiceChargeDate, buildInvoiceDocument };
|
|
148
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* List endpoints' ?filter / ?sort / ?fields / ?page&limit handling, for every backend. Two styles, because the
|
|
3
|
+
* dashboards send different kinds of filters:
|
|
4
|
+
* - "search" (Ops, Employee): text fields match case-insensitively ("a,b" = either), numbers take "gte:5",
|
|
5
|
+
* id fields must be valid ids, and it also works on aggregation pipelines.
|
|
6
|
+
* - "exact" (Client, Partner): exact matches, "field[gte]=..." ranges and createdAt dates.
|
|
7
|
+
* A backend's utils/api-features.js is `module.exports = createListQuery({ style, ... })`, so its routes keep
|
|
8
|
+
* calling `new APIFeatures(query, req.query).filter().sort().limitFields().paginate(total)`.
|
|
9
|
+
*/
|
|
10
|
+
export type ListQueryStyle = "search" | "exact";
|
|
11
|
+
export interface ListQueryOptions {
|
|
12
|
+
style: ListQueryStyle;
|
|
13
|
+
/** A filter from the query string may narrow a list but never replace a condition the route already set
|
|
14
|
+
* (Mongoose's find() overwrites same-named keys, so ?companyId=<another id> would swap in someone else's). Top-level
|
|
15
|
+
* $ operators are never taken from the URL either. Only applies to find() queries. */
|
|
16
|
+
lockBaseFilter: boolean;
|
|
17
|
+
/** Page size when ?limit isn't given. */
|
|
18
|
+
defaultLimit: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function createListQuery(options: ListQueryOptions): {
|
|
21
|
+
new (query: any, queryString: any): {
|
|
22
|
+
query: any;
|
|
23
|
+
queryString: any;
|
|
24
|
+
paginationResult?: {
|
|
25
|
+
total: number;
|
|
26
|
+
currentPage: number;
|
|
27
|
+
totalPages: number;
|
|
28
|
+
};
|
|
29
|
+
filter(): /*elided*/ any;
|
|
30
|
+
sort(): /*elided*/ any;
|
|
31
|
+
limitFields(): /*elided*/ any;
|
|
32
|
+
paginate(totalDocuments: number): /*elided*/ any;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
//# sourceMappingURL=listQuery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"listQuery.d.ts","sourceRoot":"","sources":["../../src/helpers/listQuery.ts"],"names":[],"mappings":"AAEA;;;;;;;;GAQG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEhD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,cAAc,CAAC;IACtB;;2FAEuF;IACvF,cAAc,EAAE,OAAO,CAAC;IACxB,yCAAyC;IACzC,YAAY,EAAE,MAAM,CAAC;CACtB;AAsGD,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB;gBAQlC,GAAG,eAAe,GAAG;eAJjC,GAAG;qBACG,GAAG;2BACG;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,WAAW,EAAE,MAAM,CAAC;YAAC,UAAU,EAAE,MAAM,CAAA;SAAE;;;;iCAqDpD,MAAM;;EAmBlC"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createListQuery = createListQuery;
|
|
4
|
+
const mongoose_1 = require("mongoose");
|
|
5
|
+
const PAGING_KEYS = ["page", "sort", "limit", "fields"];
|
|
6
|
+
// "a,b" -> any of them, case-insensitive; one value -> contains, case-insensitive. Empty -> drop the filter.
|
|
7
|
+
const textMatch = (value) => {
|
|
8
|
+
if (!value.includes(","))
|
|
9
|
+
return new RegExp(value, "i");
|
|
10
|
+
const values = value.split(",").map((v) => v.trim()).filter(Boolean);
|
|
11
|
+
return values.length ? { $in: values.map((v) => new RegExp(v, "i")) } : undefined;
|
|
12
|
+
};
|
|
13
|
+
function searchFilter(query, queryObj) {
|
|
14
|
+
var _a, _b;
|
|
15
|
+
for (const key in queryObj) {
|
|
16
|
+
const fieldType = (_b = (_a = query === null || query === void 0 ? void 0 : query.schema) === null || _a === void 0 ? void 0 : _a.paths[key]) === null || _b === void 0 ? void 0 : _b.instance;
|
|
17
|
+
let value = queryObj[key];
|
|
18
|
+
if (typeof value !== "string") {
|
|
19
|
+
delete queryObj[key];
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (value === "true")
|
|
23
|
+
value = true;
|
|
24
|
+
else if (value === "false")
|
|
25
|
+
value = false;
|
|
26
|
+
if (fieldType === "Boolean") {
|
|
27
|
+
queryObj[key] = value;
|
|
28
|
+
}
|
|
29
|
+
else if (fieldType === "ObjectId") {
|
|
30
|
+
if (mongoose_1.Types.ObjectId.isValid(value))
|
|
31
|
+
queryObj[key] = new mongoose_1.Types.ObjectId(value);
|
|
32
|
+
else
|
|
33
|
+
delete queryObj[key];
|
|
34
|
+
}
|
|
35
|
+
else if (fieldType === "Number") {
|
|
36
|
+
if (typeof value !== "string") {
|
|
37
|
+
delete queryObj[key]; // "true"/"false" on a number field (it used to crash the request)
|
|
38
|
+
}
|
|
39
|
+
else if (value.includes(":")) {
|
|
40
|
+
const [operator, val] = value.split(":");
|
|
41
|
+
if (["gte", "gt", "lte", "lt"].includes(operator) && !isNaN(val)) {
|
|
42
|
+
queryObj[key] = { [`$${operator}`]: parseFloat(val) };
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
delete queryObj[key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (!isNaN(value)) {
|
|
49
|
+
queryObj[key] = parseFloat(value);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
delete queryObj[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// String fields and anything the schema doesn't describe (e.g. aggregation pipelines).
|
|
57
|
+
if (typeof value !== "string") {
|
|
58
|
+
queryObj[key] = value; // "true"/"false" on a non-Boolean field
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const m = textMatch(value);
|
|
62
|
+
if (m)
|
|
63
|
+
queryObj[key] = m;
|
|
64
|
+
else
|
|
65
|
+
delete queryObj[key];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function exactFilter(query, queryObj) {
|
|
70
|
+
const schema = query.model.schema;
|
|
71
|
+
Object.keys(queryObj).forEach((key) => {
|
|
72
|
+
var _a;
|
|
73
|
+
const value = queryObj[key];
|
|
74
|
+
if (value === "true") {
|
|
75
|
+
queryObj[key] = true;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (value === "false") {
|
|
79
|
+
queryObj[key] = false;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const match = key.match(/^(.+)\[(gte|gt|lte|lt)\]$/);
|
|
83
|
+
if (match) {
|
|
84
|
+
const field = match[1];
|
|
85
|
+
const operator = match[2];
|
|
86
|
+
let val = queryObj[key];
|
|
87
|
+
if (field === "createdAt" && typeof val === "string") {
|
|
88
|
+
if (val.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\s00:00$/)) {
|
|
89
|
+
val = val.replace(/\s00:00$/, "Z");
|
|
90
|
+
}
|
|
91
|
+
val = new Date(val);
|
|
92
|
+
}
|
|
93
|
+
if (!queryObj[field])
|
|
94
|
+
queryObj[field] = {};
|
|
95
|
+
queryObj[field][`$${operator}`] = val;
|
|
96
|
+
delete queryObj[key];
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const fieldType = (_a = schema.paths[key]) === null || _a === void 0 ? void 0 : _a.instance;
|
|
100
|
+
if (typeof value !== "string" || key.startsWith("$"))
|
|
101
|
+
return;
|
|
102
|
+
if (fieldType === "Number") {
|
|
103
|
+
if (!/^(gte|gt|lte|lt):/.test(value) && isNaN(value))
|
|
104
|
+
delete queryObj[key];
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
function createListQuery(options) {
|
|
109
|
+
const { style, lockBaseFilter, defaultLimit } = options;
|
|
110
|
+
return class APIFeatures {
|
|
111
|
+
constructor(query, queryString) {
|
|
112
|
+
this.query = query;
|
|
113
|
+
this.queryString = queryString;
|
|
114
|
+
}
|
|
115
|
+
filter() {
|
|
116
|
+
const queryObj = { ...this.queryString };
|
|
117
|
+
PAGING_KEYS.forEach((el) => delete queryObj[el]);
|
|
118
|
+
if (style === "search")
|
|
119
|
+
searchFilter(this.query, queryObj);
|
|
120
|
+
else
|
|
121
|
+
exactFilter(this.query, queryObj);
|
|
122
|
+
if (lockBaseFilter && typeof this.query.getFilter === "function") {
|
|
123
|
+
const fixed = this.query.getFilter();
|
|
124
|
+
Object.keys(queryObj).forEach((key) => {
|
|
125
|
+
if (key.startsWith("$") || Object.prototype.hasOwnProperty.call(fixed, key))
|
|
126
|
+
delete queryObj[key];
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (typeof this.query.find === "function") {
|
|
130
|
+
this.query = this.query.find(queryObj);
|
|
131
|
+
}
|
|
132
|
+
else if (typeof this.query.match === "function") {
|
|
133
|
+
this.query.match(queryObj);
|
|
134
|
+
}
|
|
135
|
+
return this;
|
|
136
|
+
}
|
|
137
|
+
sort() {
|
|
138
|
+
const sortBy = this.queryString.sort ? String(this.queryString.sort).split(",").join(" ") : "-createdAt";
|
|
139
|
+
this.query = this.query.sort(sortBy);
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
limitFields() {
|
|
143
|
+
if (this.queryString.fields) {
|
|
144
|
+
const fields = String(this.queryString.fields).split(",").join(" ");
|
|
145
|
+
if (typeof this.query.select === "function") {
|
|
146
|
+
this.query = this.query.select(fields);
|
|
147
|
+
}
|
|
148
|
+
else if (typeof this.query.project === "function") {
|
|
149
|
+
const projection = {};
|
|
150
|
+
fields.split(" ").forEach((f) => (projection[f] = 1));
|
|
151
|
+
this.query.project(projection);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (typeof this.query.select === "function") {
|
|
155
|
+
this.query = this.query.select("-__v");
|
|
156
|
+
}
|
|
157
|
+
else if (typeof this.query.project === "function") {
|
|
158
|
+
this.query.project({ __v: 0 });
|
|
159
|
+
}
|
|
160
|
+
return this;
|
|
161
|
+
}
|
|
162
|
+
paginate(totalDocuments) {
|
|
163
|
+
const page = this.queryString.page * 1 || 1;
|
|
164
|
+
const limit = this.queryString.limit * 1 || defaultLimit;
|
|
165
|
+
const skip = (page - 1) * limit;
|
|
166
|
+
if (typeof this.query.skip === "function") {
|
|
167
|
+
this.query = this.query.skip(skip).limit(limit);
|
|
168
|
+
}
|
|
169
|
+
else if (typeof this.query.append === "function") {
|
|
170
|
+
this.query.append([{ $skip: skip }, { $limit: limit }]);
|
|
171
|
+
}
|
|
172
|
+
this.paginationResult = {
|
|
173
|
+
total: totalDocuments,
|
|
174
|
+
currentPage: page,
|
|
175
|
+
totalPages: Math.ceil(totalDocuments / limit),
|
|
176
|
+
};
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export declare function createMyUpdatesController({ dashboard, audience, ownerKey, sse }: {
|
|
2
|
+
dashboard: string;
|
|
3
|
+
audience: string;
|
|
4
|
+
ownerKey: string;
|
|
5
|
+
sse: {
|
|
6
|
+
addClient: (res: any) => void;
|
|
7
|
+
removeClient: (res: any) => void;
|
|
8
|
+
};
|
|
9
|
+
}): {
|
|
10
|
+
getMyUpdates: (req: any, res: any, next: any) => void;
|
|
11
|
+
markRead: (req: any, res: any, next: any) => void;
|
|
12
|
+
markAllRead: (req: any, res: any, next: any) => void;
|
|
13
|
+
markReadByLink: (req: any, res: any, next: any) => void;
|
|
14
|
+
streamUpdates: (req: any, res: any) => void;
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=myUpdates.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"myUpdates.d.ts","sourceRoot":"","sources":["../../src/helpers/myUpdates.ts"],"names":[],"mappings":"AAYA,wBAAgB,yBAAyB,CAAC,EAAE,SAAS,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,EAAE;IAChF,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,GAAG,EAAE;QAAE,SAAS,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAC;QAAC,YAAY,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;KAAE,CAAC;CAC1E;;;;;yBA2D6B,GAAG,OAAO,GAAG;EAyB1C"}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createMyUpdatesController = createMyUpdatesController;
|
|
7
|
+
/**
|
|
8
|
+
* A portal user's Updates feed (the bell/Updates page): list with unread counts, mark read, mark all read, mark
|
|
9
|
+
* read by link, and the live "something changed" stream. Shared by the Client and Partner backends:
|
|
10
|
+
* module.exports = createMyUpdatesController({ dashboard: "client", audience: "company", ownerKey: "companyId", sse });
|
|
11
|
+
* module.exports = createMyUpdatesController({ dashboard: "partner", audience: "partner", ownerKey: "partnerId", sse });
|
|
12
|
+
* `sse` is the backend's own stream service ({ addClient(res), removeClient(res) }).
|
|
13
|
+
*/
|
|
14
|
+
const mongoose_1 = __importDefault(require("mongoose"));
|
|
15
|
+
const update_model_1 = require("../models/update.model");
|
|
16
|
+
const errors_1 = require("./errors");
|
|
17
|
+
const _http_1 = require("./_http");
|
|
18
|
+
function createMyUpdatesController({ dashboard, audience, ownerKey, sse }) {
|
|
19
|
+
const UpdateModel = update_model_1.Update;
|
|
20
|
+
// Mine: a broadcast to every user of this dashboard, something aimed at my whole account (company or partner),
|
|
21
|
+
// or aimed at me.
|
|
22
|
+
const myUpdatesQuery = (userId, ownerId) => ({
|
|
23
|
+
dashboard,
|
|
24
|
+
$or: [{ audience: "broadcast" }, { audience, [ownerKey]: ownerId }, { audience: "user", userId }],
|
|
25
|
+
});
|
|
26
|
+
const getMyUpdates = (0, _http_1.catchAsync)(async (req, res) => {
|
|
27
|
+
const query = myUpdatesQuery(req.user._id, req.user.owner);
|
|
28
|
+
const unreadQuery = { ...query, readBy: { $ne: req.user._id } };
|
|
29
|
+
const [updates, unreadCount, unreadGroups] = await Promise.all([
|
|
30
|
+
UpdateModel.find(query).sort({ createdAt: -1 }).limit(100).lean(),
|
|
31
|
+
UpdateModel.countDocuments(unreadQuery),
|
|
32
|
+
// Unread count per type, so the sidebar can badge Tickets and Requests separately from Updates.
|
|
33
|
+
UpdateModel.aggregate([{ $match: unreadQuery }, { $group: { _id: "$type", count: { $sum: 1 } } }]),
|
|
34
|
+
]);
|
|
35
|
+
const unreadByType = Object.fromEntries(unreadGroups.map((g) => [g._id, g.count]));
|
|
36
|
+
res.status(_http_1.HTTP.OK).json({
|
|
37
|
+
success: true,
|
|
38
|
+
results: updates.length,
|
|
39
|
+
data: {
|
|
40
|
+
updates: updates.map((u) => ({
|
|
41
|
+
...u,
|
|
42
|
+
isRead: (u.readBy || []).some((id) => String(id) === String(req.user._id)),
|
|
43
|
+
})),
|
|
44
|
+
unreadCount,
|
|
45
|
+
unreadByType,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
const markRead = (0, _http_1.catchAsync)(async (req, res, next) => {
|
|
50
|
+
if (!mongoose_1.default.Types.ObjectId.isValid(req.params.id))
|
|
51
|
+
return next(new errors_1.AppError("Invalid update id", _http_1.HTTP.BAD_REQUEST));
|
|
52
|
+
const update = await UpdateModel.findByIdAndUpdate(req.params.id, { $addToSet: { readBy: req.user._id } }, { new: true });
|
|
53
|
+
if (!update)
|
|
54
|
+
return next(new errors_1.AppError("Update not found", _http_1.HTTP.NOT_FOUND));
|
|
55
|
+
res.status(_http_1.HTTP.OK).json({ success: true, message: "Marked read" });
|
|
56
|
+
});
|
|
57
|
+
const markAllRead = (0, _http_1.catchAsync)(async (req, res) => {
|
|
58
|
+
await UpdateModel.updateMany(myUpdatesQuery(req.user._id, req.user.owner), { $addToSet: { readBy: req.user._id } });
|
|
59
|
+
res.status(_http_1.HTTP.OK).json({ success: true, message: "All updates marked read" });
|
|
60
|
+
});
|
|
61
|
+
// Opening a ticket/request marks its updates read, so its sidebar badge clears. Body: { link: "/tickets/view/<id>" }.
|
|
62
|
+
const markReadByLink = (0, _http_1.catchAsync)(async (req, res, next) => {
|
|
63
|
+
var _a;
|
|
64
|
+
const link = typeof ((_a = req.body) === null || _a === void 0 ? void 0 : _a.link) === "string" ? req.body.link.trim() : "";
|
|
65
|
+
if (!link || link.length > 300)
|
|
66
|
+
return next(new errors_1.AppError("A link is required", _http_1.HTTP.BAD_REQUEST));
|
|
67
|
+
const result = await UpdateModel.updateMany({ ...myUpdatesQuery(req.user._id, req.user.owner), link, readBy: { $ne: req.user._id } }, { $addToSet: { readBy: req.user._id } });
|
|
68
|
+
res.status(_http_1.HTTP.OK).json({ success: true, data: { marked: result.modifiedCount || 0 } });
|
|
69
|
+
});
|
|
70
|
+
// A bare "something changed" ping; the page reacts by reloading its own feed.
|
|
71
|
+
const streamUpdates = (req, res) => {
|
|
72
|
+
res.writeHead(_http_1.HTTP.OK, {
|
|
73
|
+
"Content-Type": "text/event-stream",
|
|
74
|
+
"Cache-Control": "no-cache",
|
|
75
|
+
Connection: "keep-alive",
|
|
76
|
+
"X-Accel-Buffering": "no",
|
|
77
|
+
});
|
|
78
|
+
res.write("\n");
|
|
79
|
+
sse.addClient(res);
|
|
80
|
+
const heartbeat = setInterval(() => {
|
|
81
|
+
try {
|
|
82
|
+
res.write(": ping\n\n");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
clearInterval(heartbeat);
|
|
86
|
+
}
|
|
87
|
+
}, 25000);
|
|
88
|
+
req.on("close", () => {
|
|
89
|
+
clearInterval(heartbeat);
|
|
90
|
+
sse.removeClient(res);
|
|
91
|
+
});
|
|
92
|
+
};
|
|
93
|
+
return { getMyUpdates, markRead, markAllRead, markReadByLink, streamUpdates };
|
|
94
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function createOwnActivityController({ kind }: {
|
|
2
|
+
kind: "client" | "partner";
|
|
3
|
+
}): {
|
|
4
|
+
getAccess: (req: any, res: any, next: any) => void;
|
|
5
|
+
listActivity: (req: any, res: any, next: any) => void;
|
|
6
|
+
recordExport: (req: any, res: any, next: any) => void;
|
|
7
|
+
};
|
|
8
|
+
//# sourceMappingURL=ownActivity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ownActivity.d.ts","sourceRoot":"","sources":["../../src/helpers/ownActivity.ts"],"names":[],"mappings":"AA2BA,wBAAgB,2BAA2B,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,QAAQ,GAAG,SAAS,CAAA;CAAE;;;;EAmFnF"}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.createOwnActivityController = createOwnActivityController;
|
|
7
|
+
/**
|
|
8
|
+
* A portal's own Activity log: everything done on its account, by its own users and by OrbitXPro staff (HIPAA: an
|
|
9
|
+
* account must be able to see everyone who touched its data). Shown only after Ops turns on the account's access
|
|
10
|
+
* (activityLogAccess). Shared by the Client and Partner backends:
|
|
11
|
+
* createOwnActivityController({ kind: "client" }) // Company accounts, logs filed under `company`
|
|
12
|
+
* createOwnActivityController({ kind: "partner" }) // Partner accounts, logs filed under `partner`
|
|
13
|
+
*/
|
|
14
|
+
const mongoose_1 = __importDefault(require("mongoose"));
|
|
15
|
+
const company_model_1 = require("../models/company.model");
|
|
16
|
+
const partner_model_1 = require("../models/partner.model");
|
|
17
|
+
const activityLog_model_1 = require("../models/activityLog.model");
|
|
18
|
+
const recorder_1 = require("../activity/recorder");
|
|
19
|
+
const errors_1 = require("./errors");
|
|
20
|
+
const _http_1 = require("./_http");
|
|
21
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
22
|
+
const list = (v) => String(v || "")
|
|
23
|
+
.split(",")
|
|
24
|
+
.map((s) => s.trim())
|
|
25
|
+
.filter(Boolean);
|
|
26
|
+
const KINDS = {
|
|
27
|
+
client: { Account: company_model_1.Company, field: "company", searchFields: ["actor.name", "actor.email", "details", "action", "record.label"] },
|
|
28
|
+
partner: { Account: partner_model_1.Partner, field: "partner", searchFields: ["actor.name", "actor.email", "details", "action"] },
|
|
29
|
+
};
|
|
30
|
+
function createOwnActivityController({ kind }) {
|
|
31
|
+
const { field, searchFields } = KINDS[kind];
|
|
32
|
+
const Account = KINDS[kind].Account;
|
|
33
|
+
const ActivityLogModel = activityLog_model_1.ActivityLog;
|
|
34
|
+
const accountIdOf = (req) => { var _a, _b, _c; return ((_b = (_a = req.user) === null || _a === void 0 ? void 0 : _a.owner) === null || _b === void 0 ? void 0 : _b._id) || ((_c = req.user) === null || _c === void 0 ? void 0 : _c.owner); };
|
|
35
|
+
const getAccess = (0, _http_1.catchAsync)(async (req, res) => {
|
|
36
|
+
const account = await Account.findById(accountIdOf(req)).select("activityLogAccess").lean();
|
|
37
|
+
res.status(_http_1.HTTP.OK).json({ success: true, data: { enabled: !!(account === null || account === void 0 ? void 0 : account.activityLogAccess) } });
|
|
38
|
+
});
|
|
39
|
+
const listActivity = (0, _http_1.catchAsync)(async (req, res, next) => {
|
|
40
|
+
const account = await Account.findById(accountIdOf(req)).select("activityLogAccess").lean();
|
|
41
|
+
if (!(account === null || account === void 0 ? void 0 : account.activityLogAccess)) {
|
|
42
|
+
return next(new errors_1.AppError("Your Activity log isn't turned on. Please ask OrbitXPro support to turn it on.", _http_1.HTTP.FORBIDDEN));
|
|
43
|
+
}
|
|
44
|
+
const q = req.query;
|
|
45
|
+
const page = Math.max(1, Number(q.page) || 1);
|
|
46
|
+
const limit = Math.min(200, Math.max(1, Number(q.limit) || 25));
|
|
47
|
+
const filter = { [field]: new mongoose_1.default.Types.ObjectId(String(account._id)) };
|
|
48
|
+
const actions = list(q.action);
|
|
49
|
+
if (actions.length)
|
|
50
|
+
filter.action = { $in: actions };
|
|
51
|
+
const outcomes = list(q.outcome).map((o) => o.toLowerCase());
|
|
52
|
+
if (outcomes.length)
|
|
53
|
+
filter.outcome = { $in: outcomes };
|
|
54
|
+
if (q.from || q.to) {
|
|
55
|
+
filter.at = {};
|
|
56
|
+
if (q.from)
|
|
57
|
+
filter.at.$gte = new Date(q.from);
|
|
58
|
+
if (q.to)
|
|
59
|
+
filter.at.$lte = new Date(q.to);
|
|
60
|
+
}
|
|
61
|
+
const and = [];
|
|
62
|
+
if (q.user)
|
|
63
|
+
and.push({ $or: ["actor.name", "actor.email"].map((f) => ({ [f]: { $regex: escape(String(q.user)), $options: "i" } })) });
|
|
64
|
+
if (q.details)
|
|
65
|
+
and.push({ details: { $regex: escape(String(q.details)), $options: "i" } });
|
|
66
|
+
if (q.search) {
|
|
67
|
+
const re = { $regex: escape(String(q.search)), $options: "i" };
|
|
68
|
+
and.push({ $or: searchFields.map((f) => ({ [f]: re })) });
|
|
69
|
+
}
|
|
70
|
+
if (and.length)
|
|
71
|
+
filter.$and = and;
|
|
72
|
+
const [rows, total] = await Promise.all([
|
|
73
|
+
ActivityLogModel.find(filter)
|
|
74
|
+
.sort({ at: -1 })
|
|
75
|
+
.skip((page - 1) * limit)
|
|
76
|
+
.limit(limit)
|
|
77
|
+
.select("at actor dashboard action details record outcome sensitive changes ip")
|
|
78
|
+
.lean(),
|
|
79
|
+
ActivityLogModel.countDocuments(filter),
|
|
80
|
+
]);
|
|
81
|
+
// OrbitXPro staff show by name and team; their work email and network address stay internal.
|
|
82
|
+
const items = rows.map((r) => {
|
|
83
|
+
var _a, _b, _c;
|
|
84
|
+
return ((_a = r.actor) === null || _a === void 0 ? void 0 : _a.kind) === kind
|
|
85
|
+
? r
|
|
86
|
+
: { ...r, actor: { kind: (_b = r.actor) === null || _b === void 0 ? void 0 : _b.kind, name: ((_c = r.actor) === null || _c === void 0 ? void 0 : _c.name) || "OrbitXPro", team: "OrbitXPro" }, ip: undefined };
|
|
87
|
+
});
|
|
88
|
+
res.status(_http_1.HTTP.OK).json({
|
|
89
|
+
success: true,
|
|
90
|
+
data: { items },
|
|
91
|
+
pagination: { page, limit, total, totalPages: Math.max(1, Math.ceil(total / limit)) },
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
const recordExport = (0, _http_1.catchAsync)(async (req, res) => {
|
|
95
|
+
var _a, _b;
|
|
96
|
+
const what = String(((_a = req.body) === null || _a === void 0 ? void 0 : _a.what) || "a table").slice(0, 120);
|
|
97
|
+
const rows = Number((_b = req.body) === null || _b === void 0 ? void 0 : _b.rows);
|
|
98
|
+
(0, recorder_1.recordActivity)({
|
|
99
|
+
actor: { kind, id: req.user._id, name: req.user.name, email: req.user.email, role: req.user.role },
|
|
100
|
+
dashboard: kind,
|
|
101
|
+
action: "Exported",
|
|
102
|
+
details: `Exported ${what}${Number.isFinite(rows) ? ` (CSV, ${rows} row${rows === 1 ? "" : "s"})` : ""}`,
|
|
103
|
+
[field]: accountIdOf(req),
|
|
104
|
+
sensitive: true,
|
|
105
|
+
ip: (0, recorder_1.clientIp)(req),
|
|
106
|
+
userAgent: req.headers["user-agent"],
|
|
107
|
+
method: req.method,
|
|
108
|
+
path: req.originalUrl.split("?")[0],
|
|
109
|
+
status: 200,
|
|
110
|
+
});
|
|
111
|
+
res.status(_http_1.HTTP.OK).json({ success: true });
|
|
112
|
+
});
|
|
113
|
+
return { getAccess, listActivity, recordExport };
|
|
114
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare function createOwnTicketsController({ ownerType, categoryOptions, fallbackAuthorName, uploadAttachments, raiseDispute }: {
|
|
2
|
+
ownerType: "Company" | "Partner";
|
|
3
|
+
categoryOptions: any[];
|
|
4
|
+
fallbackAuthorName: string;
|
|
5
|
+
uploadAttachments: (files: any[], ownerId: any) => Promise<any[]>;
|
|
6
|
+
/** Client only: opens the financial dispute behind a lead-dispute ticket. */
|
|
7
|
+
raiseDispute?: (input: {
|
|
8
|
+
leadId: any;
|
|
9
|
+
reason: string;
|
|
10
|
+
companyId: any;
|
|
11
|
+
raisedBy: any;
|
|
12
|
+
ticketId: any;
|
|
13
|
+
}) => Promise<any>;
|
|
14
|
+
}): {
|
|
15
|
+
getTicketCategories: (req: any, res: any, next: any) => void;
|
|
16
|
+
createTicket: (req: any, res: any, next: any) => void;
|
|
17
|
+
getMyTickets: (req: any, res: any, next: any) => void;
|
|
18
|
+
getMyTicketById: (req: any, res: any, next: any) => void;
|
|
19
|
+
addTicketMessage: (req: any, res: any, next: any) => void;
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=ownTickets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ownTickets.d.ts","sourceRoot":"","sources":["../../src/helpers/ownTickets.ts"],"names":[],"mappings":"AAuBA,wBAAgB,0BAA0B,CAAC,EAAE,SAAS,EAAE,eAAe,EAAE,kBAAkB,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE;IAC9H,SAAS,EAAE,SAAS,GAAG,SAAS,CAAC;IACjC,eAAe,EAAE,GAAG,EAAE,CAAC;IACvB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,iBAAiB,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,EAAE,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC;IAClE,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,MAAM,EAAE,GAAG,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAC;QAAC,QAAQ,EAAE,GAAG,CAAA;KAAE,KAAK,OAAO,CAAC,GAAG,CAAC,CAAC;CACvH;;;;;;EAoJA"}
|