711 lines
25 KiB
TypeScript
711 lines
25 KiB
TypeScript
import { BadRequestException, GoneException, Injectable, Logger, NotFoundException } from "@nestjs/common";
|
|
import * as crypto from "crypto";
|
|
import { google } from "googleapis";
|
|
import { Prisma } from "@prisma/client";
|
|
import * as XLSX from "xlsx";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { AbuseService } from "../abuse/abuse.service";
|
|
import { RequestContext } from "../abuse/abuse.types";
|
|
import { ExportObjectStorageService } from "./export-object-storage.service";
|
|
|
|
type ExportWatermark = {
|
|
label: string;
|
|
userId: string;
|
|
generatedAt: string;
|
|
traceId: string;
|
|
text: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class ExportsService {
|
|
private readonly logger = new Logger(ExportsService.name);
|
|
private readonly syncSheetTitle = "LedgerOne Sync";
|
|
private readonly downloadTokenTtlMs = 2 * 60 * 1000;
|
|
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly abuseService: AbuseService,
|
|
private readonly exportObjectStorage: ExportObjectStorageService,
|
|
) {}
|
|
|
|
private toCsv(rows: Array<Record<string, string>>) {
|
|
if (!rows.length) return "";
|
|
const headers = Object.keys(rows[0]);
|
|
const escape = (value: string) => `"${value.replace(/"/g, '""')}"`;
|
|
const lines = [headers.join(",")];
|
|
for (const row of rows) {
|
|
lines.push(headers.map((key) => escape(row[key] ?? "")).join(","));
|
|
}
|
|
return lines.join("\n");
|
|
}
|
|
|
|
private createWatermark(userId: string): ExportWatermark {
|
|
const generatedAt = new Date().toISOString();
|
|
const traceId = crypto.randomBytes(12).toString("hex");
|
|
const label = "LedgerOne Export Watermark";
|
|
return {
|
|
label,
|
|
userId,
|
|
generatedAt,
|
|
traceId,
|
|
text: `${label} | user=${userId} | generated=${generatedAt} | trace=${traceId}`,
|
|
};
|
|
}
|
|
|
|
private applyWatermarkToRows(rows: ReturnType<typeof this.toRows>, watermark: ExportWatermark) {
|
|
const watermarkedRows = rows.map((row) => ({ ...row, watermark: watermark.text }));
|
|
if (watermarkedRows.length) return watermarkedRows;
|
|
return [{
|
|
id: "",
|
|
date: "",
|
|
description: "",
|
|
amount: "",
|
|
category: "",
|
|
notes: "",
|
|
attribution: "",
|
|
splitMode: "",
|
|
splitMinePercent: "",
|
|
splitYoursPercent: "",
|
|
splitMineAmount: "",
|
|
splitYoursAmount: "",
|
|
hidden: "",
|
|
source: "",
|
|
watermark: watermark.text,
|
|
}];
|
|
}
|
|
|
|
private escapePdfText(value: string) {
|
|
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
|
}
|
|
|
|
private buildPdf(rows: Array<Record<string, string>>, watermark: ExportWatermark) {
|
|
const headers = rows.length ? Object.keys(rows[0]) : ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
|
const lines = [
|
|
"LedgerOne Export",
|
|
`Generated ${new Date().toISOString()}`,
|
|
`Rows ${rows.length}`,
|
|
watermark.text,
|
|
"",
|
|
headers.join(" | "),
|
|
...rows.map((row) => headers.map((header) => row[header] ?? "").join(" | ")),
|
|
].map((line) => line.length > 118 ? `${line.slice(0, 115)}...` : line);
|
|
|
|
const pageHeight = 792;
|
|
const margin = 36;
|
|
const lineHeight = 11;
|
|
const fontSize = 8;
|
|
const linesPerPage = Math.floor((pageHeight - margin * 2) / lineHeight);
|
|
const pages: string[] = [];
|
|
for (let index = 0; index < lines.length; index += linesPerPage) {
|
|
const pageLines = lines.slice(index, index + linesPerPage);
|
|
const commands = ["BT", `/F1 ${fontSize} Tf`];
|
|
pageLines.forEach((line, lineIndex) => {
|
|
const y = pageHeight - margin - lineIndex * lineHeight;
|
|
commands.push(`1 0 0 1 ${margin} ${y} Tm (${this.escapePdfText(line)}) Tj`);
|
|
});
|
|
commands.push(`1 0 0 1 ${margin} ${margin - 14} Tm (${this.escapePdfText(watermark.text)}) Tj`);
|
|
commands.push("ET");
|
|
pages.push(commands.join("\n"));
|
|
}
|
|
|
|
const objects: string[] = [];
|
|
const pageObjectIds = pages.map((_, index) => 4 + index * 2);
|
|
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>";
|
|
objects[1] = `<< /Type /Pages /Kids [${pageObjectIds.map((id) => `${id} 0 R`).join(" ")}] /Count ${pages.length} >>`;
|
|
objects[2] = "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>";
|
|
pages.forEach((content, index) => {
|
|
const pageObjectId = 4 + index * 2;
|
|
const contentObjectId = pageObjectId + 1;
|
|
objects[pageObjectId - 1] = `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 3 0 R >> >> /Contents ${contentObjectId} 0 R >>`;
|
|
objects[contentObjectId - 1] = `<< /Length ${Buffer.byteLength(content, "binary")} >>\nstream\n${content}\nendstream`;
|
|
});
|
|
|
|
let pdf = "%PDF-1.4\n";
|
|
const offsets = [0];
|
|
objects.forEach((object, index) => {
|
|
offsets.push(Buffer.byteLength(pdf, "binary"));
|
|
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
|
|
});
|
|
const xrefOffset = Buffer.byteLength(pdf, "binary");
|
|
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
|
|
for (let index = 1; index < offsets.length; index += 1) {
|
|
pdf += `${String(offsets[index]).padStart(10, "0")} 00000 n \n`;
|
|
}
|
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
|
return Buffer.from(pdf, "binary");
|
|
}
|
|
|
|
private async getTransactions(
|
|
userId: string,
|
|
filters: Record<string, string>,
|
|
limit = 1000,
|
|
) {
|
|
const where: Record<string, unknown> = { account: { userId } };
|
|
if (filters.start_date || filters.end_date) {
|
|
where.date = {
|
|
gte: filters.start_date ? new Date(filters.start_date) : undefined,
|
|
lte: filters.end_date ? new Date(filters.end_date) : undefined,
|
|
};
|
|
}
|
|
if (filters.min_amount || filters.max_amount) {
|
|
where.amount = {
|
|
gte: filters.min_amount ? Number(filters.min_amount) : undefined,
|
|
lte: filters.max_amount ? Number(filters.max_amount) : undefined,
|
|
};
|
|
}
|
|
if (filters.category) {
|
|
where.derived = {
|
|
is: { userCategory: { contains: filters.category, mode: "insensitive" } },
|
|
};
|
|
}
|
|
if (filters.source) {
|
|
where.source = { contains: filters.source, mode: "insensitive" };
|
|
}
|
|
if (filters.include_hidden !== "true") {
|
|
where.OR = [{ derived: null }, { derived: { isHidden: false } }];
|
|
}
|
|
return this.prisma.transactionRaw.findMany({
|
|
where,
|
|
include: {
|
|
derived: true,
|
|
account: {
|
|
select: {
|
|
ownershipType: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: { date: "desc" },
|
|
take: limit,
|
|
});
|
|
}
|
|
|
|
private toRows(transactions: Awaited<ReturnType<typeof this.getTransactions>>) {
|
|
return transactions.map((tx) => ({
|
|
id: tx.id,
|
|
date: tx.date.toISOString().slice(0, 10),
|
|
description: tx.description,
|
|
amount: Number(tx.amount).toFixed(2),
|
|
category: tx.derived?.userCategory ?? "",
|
|
notes: tx.derived?.userNotes ?? "",
|
|
attribution: tx.derived?.attribution ?? this.defaultAttributionForAccount(tx.account?.ownershipType),
|
|
splitMode: tx.derived?.splitMode ?? "none",
|
|
splitMinePercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
|
splitYoursPercent: this.splitPercent(tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
|
splitMineAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitMinePercent, "mine"),
|
|
splitYoursAmount: this.splitAmount(Number(tx.amount), tx.derived?.splitMode, tx.derived?.splitYoursPercent, "yours"),
|
|
hidden: tx.derived?.isHidden ? "true" : "false",
|
|
source: tx.source,
|
|
}));
|
|
}
|
|
|
|
private defaultAttributionForAccount(ownershipType?: string | null) {
|
|
if (ownershipType === "joint") return "ours";
|
|
if (ownershipType === "theirs") return "yours";
|
|
return "mine";
|
|
}
|
|
|
|
private splitPercent(mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
|
if (!mode || mode === "none") return side === "mine" ? "100.00" : "0.00";
|
|
if (mode === "equal") return "50.00";
|
|
return Number(value ?? 0).toFixed(2);
|
|
}
|
|
|
|
private splitAmount(amount: number, mode: string | null | undefined, value: unknown, side: "mine" | "yours") {
|
|
const percent = Number(this.splitPercent(mode, value, side));
|
|
return (amount * (percent / 100)).toFixed(2);
|
|
}
|
|
|
|
private hashContent(content: string | Buffer) {
|
|
return crypto.createHash("sha256").update(content).digest("hex");
|
|
}
|
|
|
|
private hashToken(token: string) {
|
|
return crypto.createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
private async createExportLog(
|
|
userId: string,
|
|
filters: Record<string, string>,
|
|
rowCount: number,
|
|
audit: {
|
|
format: string;
|
|
destination?: string;
|
|
fileName?: string;
|
|
mimeType?: string;
|
|
fileContent?: string | Buffer;
|
|
metadata?: Record<string, unknown>;
|
|
},
|
|
context?: RequestContext,
|
|
) {
|
|
await this.prisma.exportLog.create({
|
|
data: {
|
|
userId,
|
|
format: audit.format,
|
|
destination: audit.destination ?? "download",
|
|
filters,
|
|
rowCount,
|
|
fileName: audit.fileName,
|
|
mimeType: audit.mimeType,
|
|
fileHash: audit.fileContent ? this.hashContent(audit.fileContent) : undefined,
|
|
ipAddress: context?.ipAddress,
|
|
userAgent: context?.userAgent,
|
|
metadata: (audit.metadata ?? {}) as Prisma.InputJsonValue,
|
|
},
|
|
});
|
|
}
|
|
|
|
private async buildCsvFile(userId: string, filters: Record<string, string> = {}) {
|
|
const transactions = await this.getTransactions(userId, filters);
|
|
const rows = this.toRows(transactions);
|
|
const watermark = this.createWatermark(userId);
|
|
const csv = this.toCsv(this.applyWatermarkToRows(rows, watermark));
|
|
const fileName = `ledgerone-export-${new Date().toISOString().slice(0, 10)}.csv`;
|
|
|
|
return {
|
|
content: csv,
|
|
fileName,
|
|
mimeType: "text/csv",
|
|
rowCount: rows.length,
|
|
watermark,
|
|
};
|
|
}
|
|
|
|
async exportCsv(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
const file = await this.buildCsvFile(userId, filters);
|
|
|
|
await this.createExportLog(userId, filters, file.rowCount, {
|
|
format: "csv",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
fileContent: file.content,
|
|
metadata: { watermark: file.watermark },
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(userId, file.rowCount, filters, context);
|
|
|
|
return { status: "ready", csv: file.content, fileName: file.fileName, rowCount: file.rowCount };
|
|
}
|
|
|
|
private async buildJsonFile(userId: string, filters: Record<string, string> = {}) {
|
|
const transactions = await this.getTransactions(userId, filters);
|
|
const rows = this.toRows(transactions);
|
|
const watermark = this.createWatermark(userId);
|
|
const payload = {
|
|
exportedAt: new Date().toISOString(),
|
|
rowCount: rows.length,
|
|
filters,
|
|
watermark,
|
|
transactions: rows,
|
|
};
|
|
const buffer = Buffer.from(JSON.stringify(payload, null, 2), "utf8");
|
|
return {
|
|
content: buffer,
|
|
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.json`,
|
|
mimeType: "application/json",
|
|
rowCount: rows.length,
|
|
watermark,
|
|
};
|
|
}
|
|
|
|
async exportJson(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
const file = await this.buildJsonFile(userId, filters);
|
|
|
|
await this.createExportLog(userId, filters, file.rowCount, {
|
|
format: "json",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
fileContent: file.content,
|
|
metadata: { watermark: file.watermark },
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "json" }, context);
|
|
|
|
return {
|
|
status: "ready",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
base64: file.content.toString("base64"),
|
|
rowCount: file.rowCount,
|
|
};
|
|
}
|
|
|
|
private async buildXlsxFile(userId: string, filters: Record<string, string> = {}) {
|
|
const transactions = await this.getTransactions(userId, filters);
|
|
const rows = this.toRows(transactions);
|
|
const watermark = this.createWatermark(userId);
|
|
const watermarkedRows = this.applyWatermarkToRows(rows, watermark);
|
|
const headers = this.getHeaders(watermarkedRows);
|
|
const worksheet = XLSX.utils.json_to_sheet(watermarkedRows, { header: headers });
|
|
const watermarkWorksheet = XLSX.utils.json_to_sheet([{
|
|
label: watermark.label,
|
|
userId: watermark.userId,
|
|
generatedAt: watermark.generatedAt,
|
|
traceId: watermark.traceId,
|
|
watermark: watermark.text,
|
|
}]);
|
|
const workbook = XLSX.utils.book_new();
|
|
XLSX.utils.book_append_sheet(workbook, worksheet, "LedgerOne Export");
|
|
XLSX.utils.book_append_sheet(workbook, watermarkWorksheet, "Watermark");
|
|
const buffer = XLSX.write(workbook, { type: "buffer", bookType: "xlsx" }) as Buffer;
|
|
return {
|
|
content: buffer,
|
|
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.xlsx`,
|
|
mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
rowCount: rows.length,
|
|
watermark,
|
|
};
|
|
}
|
|
|
|
async exportXlsx(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
const file = await this.buildXlsxFile(userId, filters);
|
|
|
|
await this.createExportLog(userId, filters, file.rowCount, {
|
|
format: "xlsx",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
fileContent: file.content,
|
|
metadata: { watermark: file.watermark },
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "xlsx" }, context);
|
|
|
|
return {
|
|
status: "ready",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
base64: file.content.toString("base64"),
|
|
rowCount: file.rowCount,
|
|
};
|
|
}
|
|
|
|
private async buildPdfFile(userId: string, filters: Record<string, string> = {}) {
|
|
const transactions = await this.getTransactions(userId, filters);
|
|
const rows = this.toRows(transactions);
|
|
const watermark = this.createWatermark(userId);
|
|
const buffer = this.buildPdf(rows, watermark);
|
|
return {
|
|
content: buffer,
|
|
fileName: `ledgerone-export-${new Date().toISOString().slice(0, 10)}.pdf`,
|
|
mimeType: "application/pdf",
|
|
rowCount: rows.length,
|
|
watermark,
|
|
};
|
|
}
|
|
|
|
async exportPdf(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
const file = await this.buildPdfFile(userId, filters);
|
|
|
|
await this.createExportLog(userId, filters, file.rowCount, {
|
|
format: "pdf",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
fileContent: file.content,
|
|
metadata: { watermark: file.watermark },
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(userId, file.rowCount, { ...filters, format: "pdf" }, context);
|
|
|
|
return {
|
|
status: "ready",
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
base64: file.content.toString("base64"),
|
|
rowCount: file.rowCount,
|
|
};
|
|
}
|
|
|
|
async createSignedDownloadUrl(
|
|
userId: string,
|
|
format: "csv" | "json" | "xlsx" | "pdf",
|
|
filters: Record<string, string> = {},
|
|
context?: RequestContext,
|
|
) {
|
|
const token = crypto.randomBytes(32).toString("base64url");
|
|
const expiresAt = new Date(Date.now() + this.downloadTokenTtlMs);
|
|
const file = await this.buildDownloadFile(userId, format, filters);
|
|
const stored = await this.exportObjectStorage.store({
|
|
userId,
|
|
format,
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
content: file.content,
|
|
});
|
|
const fileHash = this.hashContent(file.content);
|
|
|
|
await this.prisma.exportDownloadToken.create({
|
|
data: {
|
|
userId,
|
|
tokenHash: this.hashToken(token),
|
|
format,
|
|
filters,
|
|
storageProvider: stored.provider,
|
|
storageKey: stored.key,
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
rowCount: file.rowCount,
|
|
fileHash,
|
|
expiresAt,
|
|
},
|
|
});
|
|
|
|
return {
|
|
status: "signed",
|
|
downloadUrl: `/api/exports/download/${token}`,
|
|
expiresAt: expiresAt.toISOString(),
|
|
singleUse: true,
|
|
expiresInSeconds: Math.floor(this.downloadTokenTtlMs / 1000),
|
|
storageProvider: stored.provider,
|
|
};
|
|
}
|
|
|
|
async consumeSignedDownloadUrl(token: string, context?: RequestContext) {
|
|
const tokenHash = this.hashToken(token);
|
|
const record = await this.prisma.exportDownloadToken.findUnique({ where: { tokenHash } });
|
|
if (!record) throw new NotFoundException("Download link not found.");
|
|
if (record.usedAt) throw new GoneException("Download link has already been used.");
|
|
if (record.expiresAt < new Date()) throw new GoneException("Download link has expired.");
|
|
|
|
const consumed = await this.prisma.exportDownloadToken.updateMany({
|
|
where: { id: record.id, usedAt: null, expiresAt: { gt: new Date() } },
|
|
data: { usedAt: new Date() },
|
|
});
|
|
if (consumed.count !== 1) {
|
|
throw new GoneException("Download link is no longer valid.");
|
|
}
|
|
|
|
const filters = record.filters as unknown as Record<string, string>;
|
|
const format = record.format as "csv" | "json" | "xlsx" | "pdf";
|
|
const storedFile = await this.exportObjectStorage.load(record.storageProvider, record.storageKey);
|
|
const file = {
|
|
content: storedFile.content,
|
|
fileName: record.fileName ?? `ledgerone-export-${new Date().toISOString().slice(0, 10)}.${format}`,
|
|
mimeType: record.mimeType ?? this.mimeTypeForFormat(format),
|
|
rowCount: record.rowCount ?? 0,
|
|
};
|
|
|
|
await this.createExportLog(record.userId, filters, file.rowCount, {
|
|
format,
|
|
fileName: file.fileName,
|
|
mimeType: file.mimeType,
|
|
fileContent: file.content,
|
|
metadata: {
|
|
signedUrl: true,
|
|
tokenId: record.id,
|
|
expiresAt: record.expiresAt.toISOString(),
|
|
storageProvider: record.storageProvider,
|
|
storageKey: record.storageKey,
|
|
precomputedFileHash: record.fileHash,
|
|
},
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(record.userId, file.rowCount, { ...filters, format, signedUrl: "true" }, context);
|
|
|
|
return file;
|
|
}
|
|
|
|
private async buildDownloadFile(userId: string, format: "csv" | "json" | "xlsx" | "pdf", filters: Record<string, string>) {
|
|
if (format === "csv") return this.buildCsvFile(userId, filters);
|
|
if (format === "json") return this.buildJsonFile(userId, filters);
|
|
if (format === "xlsx") return this.buildXlsxFile(userId, filters);
|
|
if (format === "pdf") return this.buildPdfFile(userId, filters);
|
|
throw new BadRequestException("Unsupported export format.");
|
|
}
|
|
|
|
private mimeTypeForFormat(format: "csv" | "json" | "xlsx" | "pdf") {
|
|
if (format === "csv") return "text/csv";
|
|
if (format === "json") return "application/json";
|
|
if (format === "xlsx") return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
|
|
if (format === "pdf") return "application/pdf";
|
|
return "application/octet-stream";
|
|
}
|
|
|
|
private getHeaders(rows: ReturnType<typeof this.toRows>) {
|
|
return rows.length
|
|
? Object.keys(rows[0])
|
|
: ["id", "date", "description", "amount", "category", "notes", "attribution", "splitMode", "splitMinePercent", "splitYoursPercent", "splitMineAmount", "splitYoursAmount", "hidden", "source"];
|
|
}
|
|
|
|
private toSheetValues(rows: ReturnType<typeof this.toRows>) {
|
|
const headers = this.getHeaders(rows);
|
|
return [
|
|
headers,
|
|
...rows.map((row) => headers.map((header) => row[header as keyof typeof row] ?? "")),
|
|
];
|
|
}
|
|
|
|
private async createGoogleSheetsClient(userId: string, failWhenDisconnected: boolean) {
|
|
const gc = await this.prisma.googleConnection.findUnique({ where: { userId } });
|
|
if (!gc || !gc.isConnected) {
|
|
if (failWhenDisconnected) {
|
|
throw new BadRequestException(
|
|
"Google account not connected. Please connect via /api/google/connect.",
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const oauth2Client = new google.auth.OAuth2(
|
|
process.env.GOOGLE_CLIENT_ID,
|
|
process.env.GOOGLE_CLIENT_SECRET,
|
|
);
|
|
oauth2Client.setCredentials({
|
|
access_token: gc.accessToken,
|
|
refresh_token: gc.refreshToken,
|
|
});
|
|
|
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
await this.prisma.googleConnection.update({
|
|
where: { userId },
|
|
data: {
|
|
accessToken: credentials.access_token ?? gc.accessToken,
|
|
lastSyncedAt: new Date(),
|
|
},
|
|
});
|
|
oauth2Client.setCredentials(credentials);
|
|
|
|
return {
|
|
gc,
|
|
sheets: google.sheets({ version: "v4", auth: oauth2Client }),
|
|
};
|
|
}
|
|
|
|
private async ensureSpreadsheet(
|
|
userId: string,
|
|
sheets: ReturnType<typeof google.sheets>,
|
|
spreadsheetId?: string | null,
|
|
) {
|
|
if (spreadsheetId) {
|
|
await this.markDriveMirror(userId, spreadsheetId, "ready");
|
|
return spreadsheetId;
|
|
}
|
|
|
|
const spreadsheet = await sheets.spreadsheets.create({
|
|
requestBody: { properties: { title: "LedgerOne User-Owned Ledger" } },
|
|
});
|
|
const createdSpreadsheetId = spreadsheet.data.spreadsheetId!;
|
|
await this.markDriveMirror(userId, createdSpreadsheetId, "ready");
|
|
return createdSpreadsheetId;
|
|
}
|
|
|
|
private async markDriveMirror(userId: string, spreadsheetId: string, status: string, syncedAt?: Date) {
|
|
await this.prisma.googleConnection.update({
|
|
where: { userId },
|
|
data: {
|
|
spreadsheetId,
|
|
driveMirrorEnabled: true,
|
|
driveMirrorStatus: status,
|
|
driveMirrorSpreadsheetUrl: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
|
...(syncedAt ? { driveMirrorLastSyncedAt: syncedAt, lastSyncedAt: syncedAt } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
private async addSheetIfMissing(
|
|
sheets: ReturnType<typeof google.sheets>,
|
|
spreadsheetId: string,
|
|
sheetTitle: string,
|
|
) {
|
|
try {
|
|
await sheets.spreadsheets.batchUpdate({
|
|
spreadsheetId,
|
|
requestBody: {
|
|
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
|
},
|
|
});
|
|
} catch (error: unknown) {
|
|
const message = error instanceof Error ? error.message : "";
|
|
if (!/already exists|duplicate/i.test(message)) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async writeSheetValues(
|
|
sheets: ReturnType<typeof google.sheets>,
|
|
spreadsheetId: string,
|
|
sheetTitle: string,
|
|
values: string[][],
|
|
clearFirst = false,
|
|
) {
|
|
if (clearFirst) {
|
|
await sheets.spreadsheets.values.clear({
|
|
spreadsheetId,
|
|
range: `'${sheetTitle}'!A:Z`,
|
|
});
|
|
}
|
|
|
|
await sheets.spreadsheets.values.update({
|
|
spreadsheetId,
|
|
range: `'${sheetTitle}'!A1`,
|
|
valueInputOption: "RAW",
|
|
requestBody: { values },
|
|
});
|
|
}
|
|
|
|
async exportSheets(userId: string, filters: Record<string, string> = {}, context?: RequestContext) {
|
|
const client = await this.createGoogleSheetsClient(userId, true);
|
|
const { gc, sheets } = client!;
|
|
const transactions = await this.getTransactions(userId, filters);
|
|
const rows = this.toRows(transactions);
|
|
|
|
const sheetTitle = `LedgerOne Export ${new Date().toISOString().slice(0, 10)}`;
|
|
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
|
|
|
await sheets.spreadsheets.batchUpdate({
|
|
spreadsheetId,
|
|
requestBody: {
|
|
requests: [{ addSheet: { properties: { title: sheetTitle } } }],
|
|
},
|
|
});
|
|
|
|
await this.writeSheetValues(sheets, spreadsheetId, sheetTitle, this.toSheetValues(rows));
|
|
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
|
|
|
await this.createExportLog(userId, filters, rows.length, {
|
|
format: "google_sheets",
|
|
destination: "google_sheets",
|
|
metadata: { spreadsheetId, sheetTitle, url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}` },
|
|
}, context);
|
|
await this.abuseService.recordExportActivity(userId, rows.length, { ...filters, destination: "google_sheets" }, context);
|
|
|
|
this.logger.log(`Exported ${rows.length} rows to Google Sheets for user ${userId}`);
|
|
|
|
return {
|
|
status: "exported",
|
|
rowCount: rows.length,
|
|
spreadsheetId,
|
|
sheetTitle,
|
|
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
|
};
|
|
}
|
|
|
|
async syncGoogleSheets(userId: string, reason = "transaction_change") {
|
|
const client = await this.createGoogleSheetsClient(userId, false);
|
|
if (!client) {
|
|
return { status: "skipped", reason: "not_connected" };
|
|
}
|
|
|
|
const { gc, sheets } = client;
|
|
const spreadsheetId = await this.ensureSpreadsheet(userId, sheets, gc.spreadsheetId);
|
|
const transactions = await this.getTransactions(userId, {}, 5000);
|
|
const rows = this.toRows(transactions);
|
|
|
|
await this.addSheetIfMissing(sheets, spreadsheetId, this.syncSheetTitle);
|
|
await this.writeSheetValues(
|
|
sheets,
|
|
spreadsheetId,
|
|
this.syncSheetTitle,
|
|
this.toSheetValues(rows),
|
|
true,
|
|
);
|
|
|
|
await this.markDriveMirror(userId, spreadsheetId, "synced", new Date());
|
|
|
|
this.logger.log(`Synced ${rows.length} rows to Google Sheets for user ${userId}`);
|
|
|
|
return {
|
|
status: "synced",
|
|
reason,
|
|
rowCount: rows.length,
|
|
spreadsheetId,
|
|
sheetTitle: this.syncSheetTitle,
|
|
url: `https://docs.google.com/spreadsheets/d/${spreadsheetId}`,
|
|
};
|
|
}
|
|
}
|