4 Commits

Author SHA1 Message Date
admins 92fd88964e Accept buyer phone in grant API and enrich existing users
payment-router now forwards name/phone from the order. Store phone on
create; for existing users fill name (when it is an email placeholder)
and phone (when empty) without overwriting meaningful data. Also stop
dropping the parsed phone column in CSV import.
2026-07-11 19:41:49 +05:00
admins 33c0d3cafd compose: db на PG18 (тег + раскладка тома)
Прод LMS на Hoster.kz переведён с PostgreSQL 16 на 18 (09.07.2026,
dump/restore по регламенту major-upgrade-docker-бд). У PG18 другая
раскладка: том монтируется в /var/lib/postgresql, кластер лежит
в подкаталоге 18/docker.

Этот файл исполняется на сборочном сервере Hetzner, где том
lms-sb_postgres_data — hot-standby, уже переведённый на PG18 18.06.
То есть db-секция с postgres:16 + mount .../data была взведённой миной:
`docker compose -f docker-compose.prod.yml up -d` пересоздал бы standby
как PG16 поверх данных PG18 → initdb на непустом томе → рестарт-луп.
Ровно так легли lms-sb-db (01.07) и crm-prod (09.07).

Прод-compose Hoster (/root/lms-sb/docker-compose.yml) в git не живёт
и выровнен на месте; бэкап — /root/backups/major-upgrade-20260709/.
2026-07-09 17:28:27 +05:00
admins 5d94949810 Redirect large/non-inline lesson files to CDN instead of proxying
ZIP/DOCX etc. download natively — no need to force attachment; and the
157 MB Second Brain Vault archive must stream from Caddy with Range,
not through the app. Proxy attachment stays for small inline types (PDF/images).
2026-07-09 11:35:45 +05:00
admins 98cc023709 Force lesson materials to download as attachment with readable filename
Direct CDN links opened PDFs inline (no Content-Disposition), which reads
as 'can't download' behind popup blockers / on mobile. New proxy route
streams the file with attachment + human name; lesson link points to it.
2026-07-08 19:28:26 +05:00
5 changed files with 147 additions and 7 deletions
+6 -2
View File
@@ -23,14 +23,18 @@ services:
condition: service_started condition: service_started
db: db:
image: postgres:16-alpine # ⚠️ PG18 сменил раскладку: том монтируется в /var/lib/postgresql (НЕ .../data),
# кластер лежит в подкаталоге 18/docker. Мажор прода сделан 09.07.2026.
# Не откатывать тег и mount по отдельности — иначе initdb на непустом томе → рестарт-луп.
# В этом каталоге на Hetzner том lms-sb_postgres_data = hot-standby (тоже PG18).
image: postgres:18-alpine
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: lms_user POSTGRES_USER: lms_user
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: lms_db POSTGRES_DB: lms_db
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"] test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
interval: 5s interval: 5s
@@ -149,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
{lesson.files.map((file) => ( {lesson.files.map((file) => (
<a <a
key={file.id} key={file.id}
href={file.url} href={`/api/lesson-files/${file.id}/download`}
target="_blank" download={file.name}
rel="noopener noreferrer"
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]" className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
style={{ border: "2px solid var(--border)" }} style={{ border: "2px solid var(--border)" }}
> >
@@ -200,6 +200,7 @@ export async function applyImport(
data: { data: {
name: row.name, name: row.name,
email: row.email, email: row.email,
phone: row.phone || null,
emailVerified: options.autoVerifyEmail, emailVerified: options.autoVerifyEmail,
role: "student", role: "student",
}, },
@@ -231,10 +232,16 @@ export async function applyImport(
created++; created++;
} else if (row.status === "update" && row.existingId) { } else if (row.status === "update" && row.existingId) {
const existing = await prisma.user.findUnique({
where: { id: row.existingId },
select: { phone: true },
});
await prisma.user.update({ await prisma.user.update({
where: { id: row.existingId }, where: { id: row.existingId },
data: { data: {
name: row.name || undefined, name: row.name || undefined,
// don't overwrite an already-filled phone
phone: existing?.phone ? undefined : row.phone || undefined,
}, },
}); });
+15 -2
View File
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
let body: { let body: {
email?: string; email?: string;
name?: string; name?: string;
phone?: string;
course_slugs?: string[]; course_slugs?: string[];
order_id?: string; order_id?: string;
}; };
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
const email = (body.email ?? "").trim().toLowerCase(); const email = (body.email ?? "").trim().toLowerCase();
const name = (body.name ?? "").trim(); const name = (body.name ?? "").trim();
const phone = (body.phone ?? "").trim().slice(0, 30);
const slugs = Array.isArray(body.course_slugs) const slugs = Array.isArray(body.course_slugs)
? body.course_slugs.filter((s) => typeof s === "string" && s.trim()) ? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
: []; : [];
@@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
// Find or create user // Find or create user
let user = await prisma.user.findFirst({ let user = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } }, where: { email: { equals: email, mode: "insensitive" } },
select: { id: true, name: true }, select: { id: true, name: true, email: true, phone: true },
}); });
let tempPassword: string | null = null; let tempPassword: string | null = null;
if (!user) { if (!user) {
@@ -76,14 +78,25 @@ export async function POST(request: NextRequest) {
data: { data: {
email, email,
name: name || email, name: name || email,
phone: phone || null,
emailVerified: true, emailVerified: true,
mustChangePassword: true, mustChangePassword: true,
accounts: { accounts: {
create: { accountId: email, providerId: "credential", password: hash }, create: { accountId: email, providerId: "credential", password: hash },
}, },
}, },
select: { id: true, name: true }, select: { id: true, name: true, email: true, phone: true },
}); });
} else {
// Enrich existing user from the order, never overwriting meaningful data:
// name only if the current one is empty or an email placeholder, phone only if empty.
const enrich: { name?: string; phone?: string } = {};
if (name && (!user.name || user.name === user.email)) enrich.name = name;
if (phone && !user.phone) enrich.phone = phone;
if (Object.keys(enrich).length > 0) {
await prisma.user.update({ where: { id: user.id }, data: enrich });
if (enrich.name) user.name = enrich.name;
}
} }
// Enroll + audit log // Enroll + audit log
@@ -0,0 +1,117 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
// Streams a lesson material from the CDN with a forced attachment header and a
// human-readable filename. Direct CDN links open the PDF inline in a new tab
// (no Content-Disposition), which reads as "can't download" for users with a
// popup blocker or on mobile. This route makes the browser save the file.
const CONTENT_TYPES: Record<string, string> = {
pdf: "application/pdf",
zip: "application/zip",
doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
ppt: "application/vnd.ms-powerpoint",
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
};
function extFromUrl(url: string): string {
const clean = url.split("?")[0];
const dot = clean.lastIndexOf(".");
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
}
// Types a browser renders inline (so the direct link "opens in a tab" instead of
// downloading). Only these need proxying with a forced attachment header. ZIP,
// DOCX, etc. already download natively → redirect straight to the CDN.
const INLINE_EXT = new Set([
"pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif",
"txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav",
]);
// Above this, don't proxy through the app — redirect so the file streams from
// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive).
const PROXY_MAX_BYTES = 50 * 1024 * 1024;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}
const { id } = await params;
const file = await prisma.lessonFile.findUnique({
where: { id },
include: { lesson: { include: { module: { select: { courseId: true } } } } },
});
if (!file) {
return NextResponse.json({ error: "not_found" }, { status: 404 });
}
// Access: staff always; students must be enrolled in the file's course.
const role = session.user.role;
if (role !== "admin" && role !== "curator") {
const enrollment = await prisma.courseEnrollment.findFirst({
where: { userId: session.user.id, courseId: file.lesson.module.courseId },
select: { userId: true },
});
if (!enrollment) {
return NextResponse.json({ error: "forbidden" }, { status: 403 });
}
}
const ext = extFromUrl(file.url);
// Non-inline types download natively — send the browser straight to the CDN
// (native download, Range/resume, no app load). Auth is already enforced above.
if (!INLINE_EXT.has(ext)) {
return NextResponse.redirect(file.url, 302);
}
const upstream = await fetch(file.url);
if (!upstream.ok || !upstream.body) {
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
}
// Guard: a very large inline file still shouldn't stream through the app.
const upstreamLen = Number(upstream.headers.get("content-length") ?? "0");
if (upstreamLen > PROXY_MAX_BYTES) {
await upstream.body.cancel();
return NextResponse.redirect(file.url, 302);
}
const contentType =
upstream.headers.get("content-type") ??
CONTENT_TYPES[ext] ??
"application/octet-stream";
// Build a readable filename: "<name>.<ext>" (avoid doubling the extension).
let downloadName = file.name.trim();
if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) {
downloadName += `.${ext}`;
}
const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, "");
const disposition =
`attachment; filename="${asciiName}"; ` +
`filename*=UTF-8''${encodeURIComponent(downloadName)}`;
const respHeaders: Record<string, string> = {
"Content-Type": contentType,
"Content-Disposition": disposition,
"Cache-Control": "private, no-store",
};
const len = upstream.headers.get("content-length");
if (len) respHeaders["Content-Length"] = len;
return new NextResponse(upstream.body, { status: 200, headers: respHeaders });
}