Files
silo/internal/db/projects.go
Forbes 50923cf56d feat: production release with React SPA, file attachments, and deploy tooling
Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
2026-02-07 13:35:22 -06:00

305 lines
7.8 KiB
Go

package db
import (
"context"
"time"
"github.com/jackc/pgx/v5"
)
// Project represents a project in the database.
type Project struct {
ID string
Code string
Name string
Description string
CreatedAt time.Time
CreatedBy *string
}
// ProjectRepository provides project database operations.
type ProjectRepository struct {
db *DB
}
// NewProjectRepository creates a new project repository.
func NewProjectRepository(db *DB) *ProjectRepository {
return &ProjectRepository{db: db}
}
// List returns all projects.
func (r *ProjectRepository) List(ctx context.Context) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT id, code, name, description, created_at
FROM projects
ORDER BY code
`)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetByCode returns a project by its code.
func (r *ProjectRepository) GetByCode(ctx context.Context, code string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE code = $1
`, code).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// GetByID returns a project by its ID.
func (r *ProjectRepository) GetByID(ctx context.Context, id string) (*Project, error) {
p := &Project{}
var name, desc *string
err := r.db.pool.QueryRow(ctx, `
SELECT id, code, name, description, created_at
FROM projects
WHERE id = $1
`, id).Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
return p, nil
}
// Create inserts a new project.
func (r *ProjectRepository) Create(ctx context.Context, p *Project) error {
return r.db.pool.QueryRow(ctx, `
INSERT INTO projects (code, name, description, created_by)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at
`, p.Code, nullIfEmpty(p.Name), nullIfEmpty(p.Description), p.CreatedBy).Scan(&p.ID, &p.CreatedAt)
}
// Update updates a project's name and description.
func (r *ProjectRepository) Update(ctx context.Context, code string, name, description string) error {
_, err := r.db.pool.Exec(ctx, `
UPDATE projects
SET name = $2, description = $3
WHERE code = $1
`, code, nullIfEmpty(name), nullIfEmpty(description))
return err
}
// Delete removes a project (will cascade to item_projects).
func (r *ProjectRepository) Delete(ctx context.Context, code string) error {
_, err := r.db.pool.Exec(ctx, `DELETE FROM projects WHERE code = $1`, code)
return err
}
// AddItemToProject associates an item with a project.
func (r *ProjectRepository) AddItemToProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
VALUES ($1, $2)
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectID)
return err
}
// AddItemToProjectByCode associates an item with a project by code.
func (r *ProjectRepository) AddItemToProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, projectCode)
return err
}
// RemoveItemFromProject removes an item's association with a project.
func (r *ProjectRepository) RemoveItemFromProject(ctx context.Context, itemID, projectID string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = $2
`, itemID, projectID)
return err
}
// RemoveItemFromProjectByCode removes an item's association with a project by code.
func (r *ProjectRepository) RemoveItemFromProjectByCode(ctx context.Context, itemID, projectCode string) error {
_, err := r.db.pool.Exec(ctx, `
DELETE FROM item_projects
WHERE item_id = $1 AND project_id = (SELECT id FROM projects WHERE code = $2)
`, itemID, projectCode)
return err
}
// GetProjectsForItem returns all projects associated with an item.
func (r *ProjectRepository) GetProjectsForItem(ctx context.Context, itemID string) ([]*Project, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.id, p.code, p.name, p.description, p.created_at
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var projects []*Project
for rows.Next() {
p := &Project{}
var name, desc *string
if err := rows.Scan(&p.ID, &p.Code, &name, &desc, &p.CreatedAt); err != nil {
return nil, err
}
if name != nil {
p.Name = *name
}
if desc != nil {
p.Description = *desc
}
projects = append(projects, p)
}
return projects, rows.Err()
}
// GetProjectCodesForItem returns project codes for an item (convenience method).
func (r *ProjectRepository) GetProjectCodesForItem(ctx context.Context, itemID string) ([]string, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT p.code
FROM projects p
JOIN item_projects ip ON ip.project_id = p.id
WHERE ip.item_id = $1
ORDER BY p.code
`, itemID)
if err != nil {
return nil, err
}
defer rows.Close()
var codes []string
for rows.Next() {
var code string
if err := rows.Scan(&code); err != nil {
return nil, err
}
codes = append(codes, code)
}
return codes, rows.Err()
}
// GetItemsForProject returns all items associated with a project.
func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID string) ([]*Item, error) {
rows, err := r.db.pool.Query(ctx, `
SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description,
i.created_at, i.updated_at, i.archived_at, i.current_revision,
i.cad_synced_at, i.cad_file_path,
i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost,
i.thumbnail_key
FROM items i
JOIN item_projects ip ON ip.item_id = i.id
WHERE ip.project_id = $1 AND i.archived_at IS NULL
ORDER BY i.part_number
`, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []*Item
for rows.Next() {
item := &Item{}
if err := rows.Scan(
&item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description,
&item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision,
&item.CADSyncedAt, &item.CADFilePath,
&item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost,
&item.ThumbnailKey,
); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
// SetItemProjects replaces all project associations for an item.
func (r *ProjectRepository) SetItemProjects(ctx context.Context, itemID string, projectCodes []string) error {
return r.db.Tx(ctx, func(tx pgx.Tx) error {
// Remove existing associations
_, err := tx.Exec(ctx, `DELETE FROM item_projects WHERE item_id = $1`, itemID)
if err != nil {
return err
}
// Add new associations
for _, code := range projectCodes {
_, err := tx.Exec(ctx, `
INSERT INTO item_projects (item_id, project_id)
SELECT $1, id FROM projects WHERE code = $2
ON CONFLICT (item_id, project_id) DO NOTHING
`, itemID, code)
if err != nil {
return err
}
}
return nil
})
}
// helper function
func nullIfEmpty(s string) *string {
if s == "" {
return nil
}
return &s
}