Files
aura-crm/production/backend/cmd/server/main.go
T
root 56ffca767d feat: offline license/trial system
7-day full-access trial, then gated behind an Ed25519-signed license key
(internal/license) verified entirely offline — no phone-home dependency.
Middleware 402s non-exempt API routes once the trial lapses with no valid
key; frontend shows a countdown banner in the last 3 days and a blocking
overlay once actually gated, both pointing at the new Settings -> Лицензия
tab. tools/gen-license mints keys for the vendor side (never ships the
private key).
2026-08-21 18:44:54 +00:00

690 lines
40 KiB
Go

package main
import (
"context"
"encoding/json"
"log"
"os"
"time"
"production/internal/aiintake"
"production/internal/analytics"
"production/internal/auth"
"production/internal/booking"
"production/internal/cartridge"
"production/internal/cartridgecatalog"
"production/internal/cash"
"production/internal/changelog"
"production/internal/client"
"production/internal/clientnotify"
"production/internal/coreclient"
"production/internal/customfields"
"production/internal/db"
"production/internal/delivery"
"production/internal/devicecatalog"
"production/internal/diagnosis"
"production/internal/doctemplates"
"production/internal/document"
"production/internal/featuremodules"
"production/internal/file"
"production/internal/gencatalog"
"production/internal/imei"
"production/internal/inventory"
"production/internal/kkm/kkmserver"
"production/internal/license"
"production/internal/loyalty"
"production/internal/manufacture"
"production/internal/maxbot"
"production/internal/modulecontrol"
"production/internal/notification"
"production/internal/notify"
"production/internal/order"
"production/internal/orderstatus"
"production/internal/payroll"
"production/internal/pcbuilder"
"production/internal/purchaseorder"
"production/internal/realtime"
"production/internal/rma"
"production/internal/sale"
"production/internal/scheduler"
"production/internal/scraper"
"production/internal/selfupdate"
"production/internal/servicecatalog"
"production/internal/settings"
"production/internal/shifts"
"production/internal/sitecontent"
"production/internal/supplier"
"production/internal/tasks"
"production/internal/tgbot"
"production/internal/tradein"
"production/internal/vkbot"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/limiter"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/joho/godotenv"
)
func main() {
godotenv.Load()
// An unset/empty JWT_SECRET would make auth.Middleware() accept HS256
// tokens signed with an empty key — fail fast at startup rather than
// silently running every staff-protected route unauthenticated.
if os.Getenv("JWT_SECRET") == "" {
log.Fatal("JWT_SECRET is not set")
}
ctx := context.Background()
pgPool, err := db.NewPostgres(ctx)
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pgPool.Close()
if err := db.RunMigrations(os.Getenv("DATABASE_URL")); err != nil {
log.Fatalf("migrations: %v", err)
}
fileHandler, err := file.NewHandler(pgPool)
if err != nil {
log.Fatalf("minio: %v", err)
}
notifyHandler := notify.NewHandler(pgPool)
clientNotifyHandler := clientnotify.NewHandler(pgPool)
tgbotHandler := tgbot.NewHandler(pgPool)
maxbotHandler := maxbot.NewHandler(pgPool)
vkbotHandler := vkbot.NewHandler(pgPool)
realtimeHub := realtime.NewHub()
realtimeHandler := realtime.NewHandler(realtimeHub)
clientHandler := client.NewHandler(pgPool)
orderHandler := order.NewHandler(pgPool, fileHandler, notifyHandler, clientNotifyHandler, realtimeHub)
orderStatusHandler := orderstatus.NewHandler(pgPool)
documentHandler := document.NewHandler(pgPool)
deliveryHandler := delivery.NewHandler(pgPool, fileHandler)
inventoryHandler := inventory.NewHandler(pgPool, notifyHandler)
pcbuilderHandler := pcbuilder.NewHandler(pgPool)
externalSources := []scraper.Source{scraper.NewRegard()}
if ts := scraper.NewTechnosuccess(os.Getenv("TECHNOSUCCESS_EMAIL"), os.Getenv("TECHNOSUCCESS_PASSWORD")); ts != nil {
externalSources = append(externalSources, ts)
}
if itp := scraper.NewITPartner(os.Getenv("ITPARTNER_LOGIN"), os.Getenv("ITPARTNER_PASSWORD")); itp != nil {
externalSources = append(externalSources, itp)
}
scraperHandler := scraper.NewHandler(pgPool, externalSources)
scraper.StartPeriodic(pgPool, externalSources, 6*time.Hour)
manufactureHandler := manufacture.NewHandler(pgPool, inventoryHandler)
notificationHandler := notification.NewHandler(pgPool, realtimeHub)
cartridgeHandler := cartridge.NewHandler(pgPool, fileHandler, notifyHandler, clientNotifyHandler, realtimeHub, inventoryHandler, manufactureHandler, notificationHandler)
saleHandler := sale.NewHandler(pgPool, inventoryHandler)
cashHandler := cash.NewHandler(pgPool, kkmserver.New(pgPool))
changelogHandler := changelog.NewHandler()
selfupdateHandler := selfupdate.NewHandler(pgPool, os.Getenv("DEPLOY_AGENT_SOCKET_PATH"), os.Getenv("DEPLOY_AGENT_TOKEN"))
payrollHandler := payroll.NewHandler(pgPool)
shiftsHandler := shifts.NewHandler(pgPool)
aiIntakeHandler := aiintake.NewHandler(pgPool)
analyticsHandler := analytics.NewHandler(pgPool)
diagnosisHandler := diagnosis.NewHandler(pgPool, fileHandler)
settingsHandler := settings.NewHandler(pgPool)
siteContentHandler := sitecontent.NewHandler(pgPool)
imeiHandler := imei.NewHandler(pgPool)
customFieldsHandler := customfields.NewHandler(pgPool)
docTemplatesHandler := doctemplates.NewHandler(pgPool)
loyaltyHandler := loyalty.NewHandler(pgPool)
bookingHandler := booking.NewHandler(pgPool, notifyHandler, clientNotifyHandler)
tasksHandler := tasks.NewHandler(pgPool)
supplierHandler := supplier.NewHandler(pgPool)
purchaseOrderHandler := purchaseorder.NewHandler(pgPool)
rmaHandler := rma.NewHandler(pgPool)
tradeInHandler := tradein.NewHandler(pgPool, fileHandler)
deviceCatalogHandler := devicecatalog.NewHandler(pgPool)
cartridgeCatalogHandler := cartridgecatalog.NewHandler(pgPool)
serviceCatalogHandler := servicecatalog.NewHandler(pgPool)
moduleControlHandler := modulecontrol.NewHandler()
featureModulesHandler := featuremodules.NewHandler(pgPool)
genCatalogHandler := gencatalog.NewHandler(pgPool)
// "Подтвердить производство" — the one action type notifications know
// about so far. Registered here (not imported by internal/notification
// itself) to keep notification free of a manufacture import; see
// notification.go's package doc.
notificationHandler.RegisterAction("confirm_production", "catalogs", func(actionCtx context.Context, payload json.RawMessage, staffID, staffName string) error {
var p struct {
RecipeID string `json:"recipe_id"`
Qty int `json:"qty"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return err
}
_, _, err := manufactureHandler.ProduceByID(actionCtx, p.RecipeID, p.Qty, "Подтверждено из уведомления", staffID, staffName)
return err
})
coreclient.StartHeartbeat(30*time.Second, func() string {
if moduleControlHandler.Maintenance() {
return "maintenance"
}
return "healthy"
})
// Daily-ish warranty-expiring reminder — see internal/scheduler.
scheduler.Start(pgPool, clientNotifyHandler, 6*time.Hour)
// Best-effort webhook registration for the client Telegram bot — a
// missing bot token/secret/URL (not configured yet, or a self-hosted
// deployment with no public HTTPS in front of it) is a silent no-op,
// not a startup failure. Re-run by restarting the process after
// changing bot settings.
go func() {
s, err := settings.Fetch(ctx, pgPool)
if err != nil {
log.Printf("tgbot: settings fetch for webhook registration failed: %v", err)
return
}
tgbot.EnsureWebhook(s, os.Getenv("TG_WEBHOOK_URL"))
maxbot.EnsureWebhook(s, os.Getenv("MAX_WEBHOOK_URL"))
vkbot.EnsureWebhook(s, os.Getenv("VK_WEBHOOK_URL"))
}()
app := fiber.New(fiber.Config{
AppName: "production-api",
// Fiber's own default (4MB) silently truncates below the 20MB limit
// order.AddPhoto/cartridge.AddPhoto actually check and document —
// without this, any upload past 4MB never reaches those handlers at
// all (fasthttp rejects it first), regardless of what the code or
// the UI's error message claims.
BodyLimit: 20 * 1024 * 1024,
// Without this, c.IP() (what every limiter.New() below keys rate
// limits on) returns the raw TCP peer address — for every request
// reaching this container through the host-level reverse proxy the
// real deployment puts in front of it, that's the proxy itself, the
// SAME address for every visitor. Confirmed live in this repo's own
// dev environment: a request from the Docker host to the published
// port arrives here as the bridge gateway IP (172.x.x.1), not
// 127.0.0.1 — hence trusting the whole private range below, not a
// literal loopback address. Effect of the bug: rate limits become a
// single shared budget for the entire app (one busy user locks out
// everyone else) and login brute-force protection does nothing
// (attacker rides the same "trusted" bucket as legitimate traffic).
EnableTrustedProxyCheck: true,
ProxyHeader: fiber.HeaderXForwardedFor,
//
// Scoped to this compose project's own default bridge subnet
// (172.24.0.0/16, per `docker network inspect production_default`)
// rather than the old 172.16.0.0/12 supernet, which also covered
// platform_net (172.23.0.0/16, shared with core's and site's own
// backend containers) and every unrelated docker project on this
// host. A container anywhere in that old range could reach this
// backend and spoof X-Forwarded-For to bypass the rate limiters;
// this narrower range still covers the actual gateway address a
// host-level reverse proxy appears as.
TrustedProxies: []string{"172.24.0.0/16", "127.0.0.1"},
})
app.Use(recover.New())
app.Use(logger.New())
app.Use(cors.New(cors.Config{
AllowOrigins: os.Getenv("CORS_ORIGINS"),
AllowCredentials: true,
}))
// Blocks every route with 503 while core has flipped this module into
// maintenance mode — placed before any business route so nothing can
// bypass it; the middleware itself exempts /health and its own
// /api/module-control/* routes (see internal/modulecontrol's doc
// comment on why a maintenance flag must never block its own toggle).
app.Use(moduleControlHandler.Middleware())
licenseSource := func() (string, time.Time, error) {
s, err := settings.Fetch(context.Background(), pgPool)
return s.LicenseKey, s.TrialStartedAt, err
}
app.Use(license.Middleware(licenseSource))
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true})
})
api := app.Group("/api")
// Route middleware is applied explicitly per-route below (as leading
// handler arguments), never via api.Group("", middleware...). Fiber
// registers a Group's middleware as an app-wide Use route scoped by
// PATH PREFIX only — not by which Group variable a route was later
// registered through (see Group.Group() / app.register() in
// vendor'd fiber: a Use route's `group` field is used solely for route
// *naming*, never for matching). Since every one of these logical
// groups shares the literal prefix "/api" (Group("", ...) resolves to
// the parent's own prefix), three sibling Group("", mw) calls each
// silently applied their middleware to every route registered *after*
// them, cumulatively, regardless of intent — discovered when adding
// realtime's rate limit here: /api/clients was carrying the "public"
// group's Max:20 limiter meant only for /track and the sales webhook.
// The same mechanism had already made /api/ai-intake (Phase 6, meant
// for every staff role) inherit cash's RequireRole("owner","manager")
// purely because it was declared after that Group() call. Explicit
// per-route handler chains have no such ordering hazard — each line
// below is a complete, self-contained chain.
publicLimit := limiter.New(limiter.Config{Max: 20})
realtimeLimit := limiter.New(limiter.Config{Max: 30})
staffAuth := auth.Middleware()
staffLimit := limiter.New(limiter.Config{Max: 60})
// Replaces the old cashOnly := auth.RequireRole("owner", "manager") /
// ownerOnly := auth.RequireRole("owner") pair with one permission per
// gated nav section (core's roles table, migrations/004_roles.sql) — a
// custom role's checkbox matrix is what actually decides access now.
// The two system roles' seeded permissions preserve today's exact
// behavior: manager gets cash+analytics (what cashOnly granted before),
// owner gets every key including settings (still the most sensitive —
// live API keys/bot tokens, not just financial figures).
cashPerm := auth.RequirePermission("cash")
analyticsPerm := auth.RequirePermission("analytics")
catalogsPerm := auth.RequirePermission("catalogs")
servicesPerm := auth.RequirePermission("services")
settingsPerm := auth.RequirePermission("settings")
orderFieldsPerm := auth.RequirePermission("order_fields")
documentTemplatesPerm := auth.RequirePermission("document_templates")
// The one deliberate exception to the "permission checkbox, not role"
// rule the comment above describes — see internal/selfupdate's own doc
// comment for why a hardcoded role check is intentional here.
deployOnly := auth.RequireRole("owner")
// Public — the tracking token itself is the access control.
api.Get("/track/:token", publicLimit, orderHandler.Track)
// Fallback for a client with an order number but no link — see
// TrackByNumber's own doc comment for why phone is required alongside it.
api.Post("/track-by-number", publicLimit, orderHandler.TrackByNumber)
// Public — business identity for the privacy-policy page and booking
// form consent text (see settings.PublicInfo's own doc comment for why
// this is a separate, narrower response than the owner-only Get above).
api.Get("/public/business-info", publicLimit, settingsHandler.PublicInfo)
// site/web's home page — see internal/sitecontent's own package doc for
// why this is the one place its content actually gets read by the
// public site, and why is_visible=true is the only filter it needs.
api.Get("/public/site-blocks", publicLimit, siteContentHandler.PublicList)
// Public — production/web's unauthenticated /pc-builder page and, via
// CORS, the site repo's own configurator link (see internal/pcbuilder's
// own package doc for why the catalog itself is just retail parts plus
// the scraper package's external_components cache, not a parallel table).
api.Get("/public/pc-components", publicLimit, pcbuilderHandler.ListComponents)
api.Post("/public/pc-builds/check", publicLimit, pcbuilderHandler.Check)
// Public — production/web's unauthenticated /book page (Phase 13).
api.Post("/bookings", publicLimit, bookingHandler.Create)
// Staff-facing counterpart — see CreateByStaff's own doc comment.
api.Post("/bookings/staff-create", staffAuth, staffLimit, bookingHandler.CreateByStaff)
api.Get("/bookings", staffAuth, staffLimit, bookingHandler.List)
api.Post("/bookings/:id/confirm", staffAuth, staffLimit, bookingHandler.Confirm)
api.Post("/bookings/:id/decline", staffAuth, staffLimit, bookingHandler.Decline)
// Задачи — internal staff to-do board (migrations/057_staff_tasks.sql),
// staffAuth only, no permission gate: unlike Касса/Аналитика it carries
// no financial data, every role sees it.
api.Get("/tasks", staffAuth, staffLimit, tasksHandler.List)
api.Post("/tasks", staffAuth, staffLimit, tasksHandler.Create)
api.Patch("/tasks/:id", staffAuth, staffLimit, tasksHandler.Update)
api.Patch("/tasks/:id/status", staffAuth, staffLimit, tasksHandler.UpdateStatus)
api.Delete("/tasks/:id", staffAuth, staffLimit, tasksHandler.Delete)
// Public — online-store has no staff JWT of its own; sale.Webhook
// authenticates via X-Module-Token instead (see internal/sale).
api.Post("/webhooks/online-store/sales", publicLimit, saleHandler.Webhook)
// Public — core is the only caller, authenticating via X-Control-Token
// instead of a staff JWT (see internal/modulecontrol).
api.Post("/module-control/restart", publicLimit, moduleControlHandler.Restart)
api.Post("/module-control/maintenance", publicLimit, moduleControlHandler.SetMaintenance)
// Public — Telegram is the only caller, authenticating via
// X-Telegram-Bot-Api-Secret-Token instead of a staff JWT (see
// internal/tgbot).
api.Post("/webhooks/telegram", publicLimit, tgbotHandler.Webhook)
api.Post("/webhooks/max", publicLimit, maxbotHandler.Webhook)
api.Post("/webhooks/vk", publicLimit, vkbotHandler.Webhook)
// Auth happens inside Subscribe itself via a query-param token, not the
// Authorization header staffAuth checks — a browser's native
// EventSource can't set custom headers (see internal/realtime's
// package doc). Separate rate budget too: this is a handful of
// long-lived streaming connections (one per open staff browser tab),
// not a rate of discrete requests like the limiters above/below.
api.Get("/events", realtimeLimit, realtimeHandler.Subscribe)
api.Post("/clients", staffAuth, staffLimit, clientHandler.Create)
api.Get("/clients", staffAuth, staffLimit, clientHandler.List)
api.Get("/clients/:id", staffAuth, staffLimit, clientHandler.Get)
api.Get("/clients/:id/debt", staffAuth, staffLimit, clientHandler.Debt)
// Client-facing notification channel (internal/clientnotify) — history
// and link/prefs are open to every staff role (same sensitivity as
// creating an order); test-notify actually sends (and, on the SMS
// path, costs money), so it's owner/manager only like the cash ledger.
api.Get("/clients/:id/notifications", staffAuth, staffLimit, clientNotifyHandler.History)
api.Post("/clients/:id/notify-link", staffAuth, staffLimit, clientNotifyHandler.CreateLink)
api.Patch("/clients/:id/notify-prefs", staffAuth, staffLimit, clientNotifyHandler.UpdatePrefs)
api.Post("/clients/:id/test-notify", staffAuth, staffLimit, cashPerm, clientNotifyHandler.TestNotify)
api.Get("/clients/:id/loyalty", staffAuth, staffLimit, loyaltyHandler.Get)
api.Post("/clients/:id/loyalty/redeem", staffAuth, staffLimit, cashPerm, loyaltyHandler.Redeem)
api.Post("/clients/:id/loyalty/adjust", staffAuth, staffLimit, cashPerm, loyaltyHandler.Adjust)
// Prizes are a manual "client won X" log entry, not a points ledger —
// open to any staff role, same as recording a comment on an order.
api.Get("/clients/:id/prizes", staffAuth, staffLimit, loyaltyHandler.ListPrizes)
api.Post("/clients/:id/prizes", staffAuth, staffLimit, loyaltyHandler.AddPrize)
// Groups carry an explicit order-number prefix (see internal/ordernum) —
// mutating them is an owner-level structural decision. Brands are plain
// labels any staff can add inline from the order form. Reads are open
// to all staff either way — both catalogs feed the order-creation form.
// Read is open to any authenticated staff — the nav needs it on every
// login to know which sections to render. Write is settingsPerm, same
// sensitivity tier as default_warranty_days et al.
api.Get("/feature-modules", staffAuth, staffLimit, featureModulesHandler.List)
api.Patch("/feature-modules/:key", staffAuth, staffLimit, settingsPerm, featureModulesHandler.Set)
// Generic, owner-defined catalogs (internal/gencatalog) — same read-open/
// write-tiered split as device-groups/device-brands below, just for
// arbitrary types instead of the two hardcoded device ones.
api.Get("/catalog-types", staffAuth, staffLimit, genCatalogHandler.ListTypes)
api.Post("/catalog-types", staffAuth, staffLimit, catalogsPerm, genCatalogHandler.CreateType)
api.Delete("/catalog-types/:id", staffAuth, staffLimit, catalogsPerm, genCatalogHandler.DeleteType)
api.Get("/catalog-entries", staffAuth, staffLimit, genCatalogHandler.ListEntries)
api.Post("/catalog-entries", staffAuth, staffLimit, genCatalogHandler.CreateEntry)
api.Delete("/catalog-entries/:id", staffAuth, staffLimit, catalogsPerm, genCatalogHandler.DeleteEntry)
// Generic raw-part -> finished-part conversion (internal/manufacture) —
// read is open to any staff (the cartridge refill flow needs to look up
// a recipe by cartridge model on every intake), write/produce is
// catalogsPerm, same tier as recipe management itself.
api.Get("/production-recipes", staffAuth, staffLimit, manufactureHandler.List)
api.Post("/production-recipes", staffAuth, staffLimit, catalogsPerm, manufactureHandler.Create)
api.Delete("/production-recipes/:id", staffAuth, staffLimit, catalogsPerm, manufactureHandler.Delete)
api.Post("/production-recipes/:id/produce", staffAuth, staffLimit, catalogsPerm, manufactureHandler.Produce)
api.Get("/cartridge-models/:modelId/recipe", staffAuth, staffLimit, manufactureHandler.GetByCartridgeModel)
// In-app notification inbox (internal/notification) — shared/global,
// open to any staff same as the read side of the things it notifies
// about (see notification.go's package doc for why there's no
// per-notification permission check).
api.Get("/notifications", staffAuth, staffLimit, notificationHandler.List)
api.Post("/notifications/:id/read", staffAuth, staffLimit, notificationHandler.MarkRead)
api.Post("/notifications/:id/action", staffAuth, staffLimit, notificationHandler.Action)
api.Delete("/notifications/:id", staffAuth, staffLimit, notificationHandler.Dismiss)
api.Get("/device-groups", staffAuth, staffLimit, deviceCatalogHandler.ListGroups)
api.Post("/device-groups", staffAuth, staffLimit, catalogsPerm, deviceCatalogHandler.CreateGroup)
api.Patch("/device-groups/:id", staffAuth, staffLimit, catalogsPerm, deviceCatalogHandler.UpdateGroup)
api.Delete("/device-groups/:id", staffAuth, staffLimit, catalogsPerm, deviceCatalogHandler.DeleteGroup)
api.Get("/device-brands", staffAuth, staffLimit, deviceCatalogHandler.ListBrands)
api.Get("/device-models/recent", staffAuth, staffLimit, deviceCatalogHandler.RecentModels)
api.Get("/cartridge-brands", staffAuth, staffLimit, cartridgeCatalogHandler.ListBrands)
api.Post("/cartridge-brands", staffAuth, staffLimit, cartridgeCatalogHandler.CreateBrand)
api.Delete("/cartridge-brands/:id", staffAuth, staffLimit, catalogsPerm, cartridgeCatalogHandler.DeleteBrand)
api.Get("/cartridge-models", staffAuth, staffLimit, cartridgeCatalogHandler.ListModels)
api.Post("/cartridge-models", staffAuth, staffLimit, cartridgeCatalogHandler.CreateModel)
api.Delete("/cartridge-models/:id", staffAuth, staffLimit, catalogsPerm, cartridgeCatalogHandler.DeleteModel)
api.Post("/device-brands", staffAuth, staffLimit, deviceCatalogHandler.CreateBrand)
api.Delete("/device-brands/:id", staffAuth, staffLimit, catalogsPerm, deviceCatalogHandler.DeleteBrand)
api.Get("/common-faults", staffAuth, staffLimit, deviceCatalogHandler.ListFaults)
api.Post("/common-faults", staffAuth, staffLimit, deviceCatalogHandler.CreateFault)
api.Delete("/common-faults/:id", staffAuth, staffLimit, catalogsPerm, deviceCatalogHandler.DeleteFault)
// "What's new" — every staff role, no permission gate, see
// internal/changelog's own package doc for why this reads CHANGELOG.md
// straight off disk rather than a DB table.
api.Get("/changelog", staffAuth, staffLimit, changelogHandler.List)
// Deploy-agent proxy (see internal/selfupdate + ../../deploy-agent) —
// owner-only, see deployOnly above. No permission check happens inside
// the handler itself; the route chain is the whole gate.
api.Get("/selfupdate/check", staffAuth, staffLimit, deployOnly, selfupdateHandler.Check)
api.Post("/selfupdate/apply", staffAuth, staffLimit, deployOnly, selfupdateHandler.Apply)
api.Get("/selfupdate/history", staffAuth, staffLimit, deployOnly, selfupdateHandler.History)
api.Post("/orders", staffAuth, staffLimit, orderHandler.Create)
api.Get("/orders", staffAuth, staffLimit, orderHandler.List)
api.Get("/orders/summary", staffAuth, staffLimit, orderHandler.Summary)
api.Get("/orders/:id", staffAuth, staffLimit, orderHandler.Get)
api.Patch("/orders/:id", staffAuth, staffLimit, orderHandler.Update)
api.Patch("/orders/:id/status", staffAuth, staffLimit, orderHandler.UpdateStatus)
api.Patch("/orders/:id/discount-approval", staffAuth, staffLimit, auth.RequirePermission("approve_discounts"), orderHandler.DiscountApproval)
api.Delete("/orders/:id", staffAuth, staffLimit, auth.RequirePermission("delete_orders"), orderHandler.Delete)
// Кастомные статусы канбана (internal/orderstatus) — read open to any
// staff (every order-status-aware view needs it on login), mutations
// catalogsPerm-gated, same tier as device groups/gencatalog types.
api.Get("/order-statuses", staffAuth, staffLimit, orderStatusHandler.List)
api.Post("/order-statuses", staffAuth, staffLimit, catalogsPerm, orderStatusHandler.Create)
api.Patch("/order-statuses/:key", staffAuth, staffLimit, catalogsPerm, orderStatusHandler.Update)
api.Delete("/order-statuses/:key", staffAuth, staffLimit, catalogsPerm, orderStatusHandler.Delete)
api.Post("/orders/:id/comments", staffAuth, staffLimit, orderHandler.AddComment)
api.Post("/orders/:id/photos", staffAuth, staffLimit, orderHandler.AddPhoto)
// QR-label recurring-item identity for printers (internal/order/printerqr.go)
// — mirrors the cartridge-recurring-items routes below.
api.Post("/orders/:id/print-printer-label", staffAuth, staffLimit, orderHandler.PrintLabel)
api.Get("/printer-recurring-items/by-code/:code", staffAuth, staffLimit, orderHandler.PrinterScanByCode)
api.Get("/printer-recurring-items/:id/qr.png", staffAuth, staffLimit, orderHandler.PrinterQRCode)
// Phase 10 — open to every staff role, same as ai-intake: a diagnostic
// shortcut a human reviews, not a financial-ledger-level endpoint.
api.Post("/orders/:id/diagnose", staffAuth, staffLimit, diagnosisHandler.Diagnose)
api.Get("/files/:key", staffAuth, staffLimit, fileHandler.Get)
api.Get("/orders/:id/invoice.pdf", staffAuth, staffLimit, documentHandler.Invoice)
api.Get("/orders/:id/act.pdf", staffAuth, staffLimit, documentHandler.Act)
api.Get("/orders/:id/receipt.pdf", staffAuth, staffLimit, documentHandler.Receipt)
api.Post("/orders/:id/parts", staffAuth, staffLimit, inventoryHandler.ReserveForOrder)
api.Get("/orders/:id/parts", staffAuth, staffLimit, inventoryHandler.ListForOrder)
api.Delete("/orders/:id/parts/:movementId", staffAuth, staffLimit, inventoryHandler.ReleaseOne)
api.Get("/orders/:id/services", staffAuth, staffLimit, orderHandler.ListServiceItems)
api.Post("/orders/:id/services", staffAuth, staffLimit, orderHandler.AddServiceItem)
api.Patch("/order-services/:itemId", staffAuth, staffLimit, orderHandler.UpdateServiceItem)
api.Delete("/order-services/:itemId", staffAuth, staffLimit, orderHandler.DeleteServiceItem)
api.Get("/service-categories", staffAuth, staffLimit, serviceCatalogHandler.ListCategories)
api.Post("/service-categories", staffAuth, staffLimit, servicesPerm, serviceCatalogHandler.CreateCategory)
api.Delete("/service-categories/:id", staffAuth, staffLimit, servicesPerm, serviceCatalogHandler.DeleteCategory)
api.Get("/services", staffAuth, staffLimit, serviceCatalogHandler.ListServices)
api.Post("/services", staffAuth, staffLimit, servicesPerm, serviceCatalogHandler.CreateService)
api.Delete("/services/:id", staffAuth, staffLimit, servicesPerm, serviceCatalogHandler.DeleteService)
api.Get("/orders/:id/cash-transactions", staffAuth, staffLimit, cashHandler.ListForOrder)
api.Post("/cartridge-batches", staffAuth, staffLimit, cartridgeHandler.Create)
api.Get("/cartridge-batches", staffAuth, staffLimit, cartridgeHandler.List)
api.Get("/cartridge-batches/:id", staffAuth, staffLimit, cartridgeHandler.Get)
api.Patch("/cartridge-batches/:id", staffAuth, staffLimit, cartridgeHandler.UpdateBatch)
api.Patch("/cartridge-batches/:id/status", staffAuth, staffLimit, cartridgeHandler.UpdateStatus)
api.Post("/cartridge-batches/:id/comments", staffAuth, staffLimit, cartridgeHandler.AddComment)
api.Post("/cartridge-batches/:id/photos", staffAuth, staffLimit, cartridgeHandler.AddPhoto)
api.Get("/cartridge-batches/:id/invoice.pdf", staffAuth, staffLimit, documentHandler.BatchInvoice)
api.Get("/cartridge-batches/:id/act.pdf", staffAuth, staffLimit, documentHandler.BatchAct)
api.Patch("/cartridge-items/:itemId", staffAuth, staffLimit, cartridgeHandler.UpdateItem)
api.Get("/cartridge-suggest", staffAuth, staffLimit, cartridgeHandler.Suggest)
api.Post("/cartridge-items/:itemId/print-label", staffAuth, staffLimit, cartridgeHandler.PrintLabel)
api.Get("/cartridge-recurring-items/by-code/:code", staffAuth, staffLimit, cartridgeHandler.ScanByCode)
api.Get("/cartridge-recurring-items/:id/qr.png", staffAuth, staffLimit, cartridgeHandler.QRCode)
api.Post("/cartridge-batches/:id/parts", staffAuth, staffLimit, inventoryHandler.ConsumeForCartridgeBatch)
api.Get("/cartridge-batches/:id/parts", staffAuth, staffLimit, inventoryHandler.ListForCartridgeBatch)
api.Get("/cartridge-batches/:id/cash-transactions", staffAuth, staffLimit, cashHandler.ListForCartridgeBatch)
api.Get("/sales", staffAuth, staffLimit, saleHandler.List)
api.Post("/sales", staffAuth, staffLimit, saleHandler.Create)
api.Get("/part-categories", staffAuth, staffLimit, inventoryHandler.ListCategories)
api.Post("/part-categories", staffAuth, staffLimit, inventoryHandler.CreateCategory)
api.Patch("/part-categories/:id", staffAuth, staffLimit, inventoryHandler.UpdateCategory)
api.Delete("/part-categories/:id", staffAuth, staffLimit, catalogsPerm, inventoryHandler.DeleteCategory)
// Staff-facing counterpart to the public /public/pc-builds/check above —
// same handler (its own compatibility logic doesn't differ by caller),
// just the CRM's own higher rate limit instead of the public one, so a
// staff member rapidly swapping components while building a quote
// doesn't hit a limit sized for anonymous site traffic.
api.Post("/pc-builds/check", staffAuth, staffLimit, pcbuilderHandler.Check)
// Manual trigger for the same refresh scraper.StartPeriodic runs on its
// own every 6h (see main() above) — catalogsPerm since it's the same
// tier as other catalog-maintenance actions (production recipes,
// device groups).
api.Post("/pc-builder/refresh-external", staffAuth, staffLimit, catalogsPerm, scraperHandler.Refresh)
// Popular-build quick-select (see migrations/054_pc_build_presets.sql) —
// list is open to any staff (the picker needs it), create/update/delete
// catalogsPerm-gated like other catalog-curation actions.
api.Get("/pc-build-presets", staffAuth, staffLimit, pcbuilderHandler.ListPresets)
api.Post("/pc-build-presets", staffAuth, staffLimit, catalogsPerm, pcbuilderHandler.CreatePreset)
api.Patch("/pc-build-presets/:id", staffAuth, staffLimit, catalogsPerm, pcbuilderHandler.UpdatePreset)
api.Delete("/pc-build-presets/:id", staffAuth, staffLimit, catalogsPerm, pcbuilderHandler.DeletePreset)
api.Post("/parts", staffAuth, staffLimit, inventoryHandler.CreatePart)
api.Get("/parts", staffAuth, staffLimit, inventoryHandler.List)
api.Get("/parts/summary", staffAuth, staffLimit, inventoryHandler.Summary)
api.Get("/parts/locations", staffAuth, staffLimit, inventoryHandler.ListLocations)
api.Get("/parts/:id", staffAuth, staffLimit, inventoryHandler.Get)
api.Patch("/parts/:id", staffAuth, staffLimit, inventoryHandler.UpdatePart)
api.Post("/parts/:id/receive", staffAuth, staffLimit, inventoryHandler.Receive)
api.Post("/parts/:id/adjust", staffAuth, staffLimit, inventoryHandler.Adjust)
api.Post("/parts/:id/barcode", staffAuth, staffLimit, inventoryHandler.GenerateBarcode)
api.Post("/parts/import-csv", staffAuth, staffLimit, inventoryHandler.ImportCSV)
api.Post("/stocktakes", staffAuth, staffLimit, inventoryHandler.CreateStocktake)
api.Get("/stocktakes", staffAuth, staffLimit, inventoryHandler.ListStocktakes)
api.Get("/stocktakes/:id", staffAuth, staffLimit, inventoryHandler.GetStocktake)
api.Patch("/stocktakes/:id/lines/:lineId", staffAuth, staffLimit, inventoryHandler.SetLineCount)
api.Post("/stocktakes/:id/complete", staffAuth, staffLimit, inventoryHandler.CompleteStocktake)
api.Post("/suppliers", staffAuth, staffLimit, supplierHandler.Create)
api.Get("/suppliers", staffAuth, staffLimit, supplierHandler.List)
api.Get("/suppliers/:id", staffAuth, staffLimit, supplierHandler.Get)
api.Patch("/suppliers/:id", staffAuth, staffLimit, supplierHandler.Update)
api.Post("/purchase-orders", staffAuth, staffLimit, purchaseOrderHandler.Create)
api.Get("/purchase-orders", staffAuth, staffLimit, purchaseOrderHandler.List)
api.Get("/purchase-orders/:id", staffAuth, staffLimit, purchaseOrderHandler.Get)
api.Patch("/purchase-orders/:id", staffAuth, staffLimit, purchaseOrderHandler.Update)
api.Patch("/purchase-orders/:id/status", staffAuth, staffLimit, purchaseOrderHandler.UpdateStatus)
api.Post("/purchase-orders/:id/receive", staffAuth, staffLimit, purchaseOrderHandler.Receive)
api.Post("/rma", staffAuth, staffLimit, rmaHandler.Create)
api.Get("/rma", staffAuth, staffLimit, rmaHandler.List)
api.Get("/rma/:id", staffAuth, staffLimit, rmaHandler.Get)
api.Patch("/rma/:id/status", staffAuth, staffLimit, rmaHandler.UpdateStatus)
// Внутренняя доставка (internal/delivery) — open to any staff, same
// operational tier as RMA above; courier is a штатный сотрудник, no
// external courier-service integration.
api.Post("/deliveries", staffAuth, staffLimit, deliveryHandler.Create)
api.Get("/deliveries", staffAuth, staffLimit, deliveryHandler.List)
api.Get("/deliveries/:id", staffAuth, staffLimit, deliveryHandler.Get)
api.Patch("/deliveries/:id", staffAuth, staffLimit, deliveryHandler.Update)
api.Patch("/deliveries/:id/status", staffAuth, staffLimit, deliveryHandler.UpdateStatus)
api.Post("/deliveries/:id/signature", staffAuth, staffLimit, deliveryHandler.UploadSignature)
api.Post("/trade-ins", staffAuth, staffLimit, tradeInHandler.Create)
api.Post("/trade-ins/estimate", staffAuth, staffLimit, tradeInHandler.Estimate)
api.Get("/trade-ins", staffAuth, staffLimit, tradeInHandler.List)
api.Get("/trade-ins/:id", staffAuth, staffLimit, tradeInHandler.Get)
// cashPerm — Complete actually moves money (cash expense) or loyalty
// credit, same gate as internal/cash's own Create/internal/
// clientnotify's TestNotify (anything that spends real value).
api.Post("/trade-ins/:id/complete", staffAuth, staffLimit, cashPerm, tradeInHandler.Complete)
api.Post("/trade-ins/:id/reject", staffAuth, staffLimit, tradeInHandler.Reject)
api.Post("/trade-ins/:id/photos", staffAuth, staffLimit, tradeInHandler.AddPhoto)
api.Get("/trade-ins/:id/photos", staffAuth, staffLimit, tradeInHandler.ListPhotos)
api.Post("/trade-ins/:id/list-for-sale", staffAuth, staffLimit, tradeInHandler.ListForSale)
api.Post("/stock-movements/:movementId/reverse", staffAuth, staffLimit, inventoryHandler.Reverse)
// Full ledger (incl. payroll) is owner/manager only — masters keep
// access to the order/batch-scoped views above (ListForOrder,
// ListForCartridgeBatch), which only ever show transactions tied to
// the specific order or batch they're already working on.
api.Post("/cash-transactions", staffAuth, staffLimit, cashPerm, cashHandler.Create)
api.Get("/cash-transactions", staffAuth, staffLimit, cashPerm, cashHandler.List)
api.Post("/cash-transactions/:id/confirm", staffAuth, staffLimit, cashPerm, cashHandler.Confirm)
api.Get("/cash-summary", staffAuth, staffLimit, cashPerm, cashHandler.Summary)
api.Get("/cash-expected-ready", staffAuth, staffLimit, cashPerm, cashHandler.ExpectedFromReadyOrders)
api.Post("/registers", staffAuth, staffLimit, cashPerm, cashHandler.CreateRegister)
api.Get("/registers", staffAuth, staffLimit, cashPerm, cashHandler.ListRegisters)
api.Patch("/registers/:id", staffAuth, staffLimit, cashPerm, cashHandler.Update)
api.Post("/cash-transfers", staffAuth, staffLimit, cashPerm, cashHandler.Transfer)
// Payroll (internal/payroll) — money-tier, same cashPerm gate as the
// rest of this block. Rate configuration and running/paying out
// payroll are both structural/financial decisions, not day-to-day
// operational ones like RMA/delivery above.
api.Get("/payroll/rates", staffAuth, staffLimit, cashPerm, payrollHandler.ListRates)
api.Put("/payroll/rates/:staffId", staffAuth, staffLimit, cashPerm, payrollHandler.UpsertRate)
api.Get("/payroll/cartridge-rates", staffAuth, staffLimit, cashPerm, payrollHandler.ListCartridgeRates)
api.Put("/payroll/cartridge-rates/:modelId", staffAuth, staffLimit, cashPerm, payrollHandler.UpsertCartridgeRate)
api.Post("/payroll/runs", staffAuth, staffLimit, cashPerm, payrollHandler.CreateRun)
api.Get("/payroll/runs", staffAuth, staffLimit, cashPerm, payrollHandler.ListRuns)
api.Get("/payroll/runs/:id", staffAuth, staffLimit, cashPerm, payrollHandler.Get)
api.Delete("/payroll/runs/:id", staffAuth, staffLimit, cashPerm, payrollHandler.DeleteRun)
api.Post("/payroll/runs/:id/adjustments", staffAuth, staffLimit, cashPerm, payrollHandler.AddAdjustment)
api.Delete("/payroll/adjustments/:id", staffAuth, staffLimit, cashPerm, payrollHandler.DeleteAdjustment)
api.Post("/payroll/runs/:id/pay", staffAuth, staffLimit, cashPerm, payrollHandler.PayRun)
api.Get("/payroll/summary/:staffId", staffAuth, staffLimit, cashPerm, payrollHandler.Summary)
// my-summary is deliberately staffAuth-only (no cashPerm) — every staff
// member, including a master with no money-tier permissions, can see
// their own dashboard earnings widget. The handler ignores any staff_id
// input and always reads the caller's own identity off the token.
api.Get("/payroll/my-summary", staffAuth, staffLimit, payrollHandler.MySummary)
// Смены — the "are you working today?" popup (any staff, self-service)
// and the owner/manager schedule view (cashPerm, same tier as Payroll).
api.Post("/shifts", staffAuth, staffLimit, shiftsHandler.Answer)
api.Get("/shifts/mine", staffAuth, staffLimit, shiftsHandler.Mine)
api.Get("/shifts", staffAuth, staffLimit, cashPerm, shiftsHandler.List)
api.Post("/shifts/:staffId", staffAuth, staffLimit, cashPerm, shiftsHandler.AnswerFor)
api.Post("/ai-intake", staffAuth, staffLimit, aiIntakeHandler.Extract)
api.Get("/imei-lookup", staffAuth, staffLimit, imeiHandler.Lookup)
// Phase 9 — reporting. Same owner/manager restriction as the cash
// ledger (by user decision) since revenue/expense aggregates are
// derived from the same financial data.
api.Get("/analytics/revenue", staffAuth, staffLimit, analyticsPerm, analyticsHandler.Revenue)
api.Get("/analytics/operations", staffAuth, staffLimit, analyticsPerm, analyticsHandler.Operations)
api.Get("/analytics/inventory", staffAuth, staffLimit, analyticsPerm, analyticsHandler.Inventory)
api.Get("/analytics/ai-summary", staffAuth, staffLimit, analyticsPerm, analyticsHandler.AISummary)
api.Get("/analytics/shop", staffAuth, staffLimit, analyticsPerm, analyticsHandler.Shop)
api.Get("/analytics/dashboard", staffAuth, staffLimit, analyticsPerm, analyticsHandler.Dashboard)
// settingsPerm — see the comment on its declaration above; still the
// most sensitive gate, holds live API keys/bot tokens.
// Open to any staff role (not settingsPerm) — the trial countdown banner
// is for everyone, not just the owner who manages the rest of Settings.
api.Get("/license/status", staffAuth, staffLimit, license.StatusHandler(licenseSource))
api.Get("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Get)
api.Put("/settings", staffAuth, staffLimit, settingsPerm, settingsHandler.Update)
// Конструктор сайта — same settingsPerm tier as the rest of Настройки,
// not a new permission key (site content is exactly as sensitive as any
// other settings-gated capability, custom_code's unsanitized-embed
// power included).
api.Get("/site-blocks", staffAuth, staffLimit, settingsPerm, siteContentHandler.List)
api.Post("/site-blocks", staffAuth, staffLimit, settingsPerm, siteContentHandler.Create)
api.Patch("/site-blocks/:id", staffAuth, staffLimit, settingsPerm, siteContentHandler.Update)
api.Delete("/site-blocks/:id", staffAuth, staffLimit, settingsPerm, siteContentHandler.Delete)
api.Post("/site-blocks/reorder", staffAuth, staffLimit, settingsPerm, siteContentHandler.Reorder)
// Custom order-intake fields (internal/customfields). List is every
// role — the intake form itself needs it to render; managing
// definitions (including seeing archived ones) is owner-only.
api.Get("/order-fields", staffAuth, staffLimit, customFieldsHandler.List)
api.Get("/order-fields/all", staffAuth, staffLimit, orderFieldsPerm, customFieldsHandler.ListAll)
api.Post("/order-fields", staffAuth, staffLimit, orderFieldsPerm, customFieldsHandler.Create)
api.Patch("/order-fields/:id", staffAuth, staffLimit, orderFieldsPerm, customFieldsHandler.Update)
// Document template editor (internal/doctemplates) — owner-only; every
// role still generates PDFs via the routes above, which call
// doctemplates.Fetch directly rather than going through this API.
api.Get("/document-templates/:kind", staffAuth, staffLimit, documentTemplatesPerm, docTemplatesHandler.Get)
api.Put("/document-templates/:kind", staffAuth, staffLimit, documentTemplatesPerm, docTemplatesHandler.Update)
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":3000"
}
log.Fatal(app.Listen(addr))
}