56ffca767d
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).
690 lines
40 KiB
Go
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))
|
|
}
|