28 Commits

Author SHA1 Message Date
H1K0 b774d2b3c9 style(backend): improve error handling 2025-10-10 01:37:01 +03:00
H1K0 d124229308 fix(backend/infrastructure/persistence/postgres): some fixes on FileRepository methods 2025-10-10 00:31:51 +03:00
H1K0 bf7a11076f fix(backend/infrastructure/persistence/postgres): add testing connection to db and improve error messages 2025-10-09 21:01:38 +03:00
H1K0 c7176fadf6 fix(backend/domain): fix typo in error code 2025-10-09 01:46:10 +03:00
H1K0 bc4354bf7b refactor(backend/domain): get rid of pgx types 2025-10-09 01:45:43 +03:00
H1K0 00ab98072b feat(backend/repositories): add get tags by file method
refactor(backend/domain): start switching from pgx types
2025-10-09 01:39:19 +03:00
H1K0 c39d82fafd refactor(backend): switch to DDD 2025-10-09 00:59:30 +03:00
H1K0 4e0fc431e2 refactor(backend/domain): rename domain.go -> entity.go 2025-10-08 20:20:51 +03:00
H1K0 7384751d6b fix(backend/storage/postgres): fix FileGetAccess function 2025-07-05 15:38:31 +03:00
H1K0 49952c62ef refactor(backend): change backend file structure 2025-07-05 14:50:44 +03:00
H1K0 057ba22b18 feat(backend/db): add function to get user's rights to a file 2025-07-04 23:21:53 +03:00
H1K0 35d41a46c0 fix(backend/db): fix error and status handling in some functions 2025-07-04 22:46:00 +03:00
H1K0 dbc34a8e0d feat(db): add recursive function to add tags to file including autotags 2025-07-04 02:32:47 +03:00
H1K0 912c7b80a3 feat(backend/db): add basic CRUD functions for files 2025-07-04 00:37:06 +03:00
H1K0 900770ff36 docs(erd): add can_create column to system.users 2025-07-03 20:19:45 +03:00
H1K0 7062cb630e feat(backend/models): add CanCreate attribute to user model 2025-07-03 20:18:47 +03:00
H1K0 436f164ab9 feat(db): add can_create column to system.users 2025-07-03 20:17:44 +03:00
H1K0 d9ca913620 refactor(backend/models): make file's Metadata not nullable 2025-07-03 20:10:42 +03:00
H1K0 153020b9c7 refactor(db): make metadata in data.files not nullable 2025-07-03 19:33:51 +03:00
H1K0 effcd2c073 feat(db): add is_deleted column to data.files 2025-07-03 19:00:55 +03:00
H1K0 78885b3656 docs(erd): add is_deleted column to data.files 2025-07-03 18:57:53 +03:00
H1K0 828d611f4d fix(backend/db): set status code 200 when no error in handleDBError 2025-07-03 18:34:48 +03:00
H1K0 82bd446a85 feat(backend/models): add aggregated fields to full file, tag, pool models 2025-07-03 18:23:46 +03:00
H1K0 24075e5a76 style(backend/models): change JSON names to camelCase 2025-07-03 18:07:33 +03:00
H1K0 27184bf17a refactor(backend/models): separate core, item and full models 2025-07-03 18:02:46 +03:00
H1K0 ec17dfb0ce feat(backend/db): add database error handler 2025-07-03 17:31:17 +03:00
H1K0 6e3328ca83 refactor(db): make metadata in data.files nullable 2025-07-03 16:19:34 +03:00
H1K0 164ea9a6c8 feat(db): add uuid_extract_timestamp function 2025-07-03 16:18:44 +03:00
11 changed files with 923 additions and 343 deletions
-50
View File
@@ -1,50 +0,0 @@
package db
import (
"context"
"fmt"
"net/http"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var connPool *pgxpool.Pool
func InitDB(connString string) error {
poolConfig, err := pgxpool.ParseConfig(connString)
if err != nil {
return fmt.Errorf("error while parsing connection string: %w", err)
}
poolConfig.MaxConns = 100
poolConfig.MinConns = 0
poolConfig.MaxConnLifetime = time.Hour
poolConfig.HealthCheckPeriod = 30 * time.Second
connPool, err = pgxpool.NewWithConfig(context.Background(), poolConfig)
if err != nil {
return fmt.Errorf("error while initializing DB connections pool: %w", err)
}
return nil
}
func transaction(handler func(context.Context, pgx.Tx) (statusCode int, err error)) (statusCode int, err error) {
ctx := context.Background()
tx, err := connPool.Begin(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
return
}
statusCode, err = handler(ctx, tx)
if err != nil {
tx.Rollback(ctx)
return
}
err = tx.Commit(ctx)
if err != nil {
statusCode = http.StatusInternalServerError
}
return
}
+119
View File
@@ -0,0 +1,119 @@
package domain
import (
"encoding/json"
"time"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"isAdmin"`
CanCreate bool `json:"canCreate"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type (
CategoryCore struct {
ID *string `json:"id"`
Name *string `json:"name"`
Color *string `json:"color"`
}
CategoryItem struct {
CategoryCore
}
CategoryFull struct {
CategoryCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
}
)
type (
FileCore struct {
ID string `json:"id"`
Name *string `json:"name"`
MIME MIME `json:"mime"`
}
FileItem struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
}
FileFull struct {
FileCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
Metadata json.RawMessage `json:"metadata"`
Viewed int `json:"viewed"`
}
)
type (
TagCore struct {
ID string `json:"id"`
Name string `json:"name"`
Color *string `json:"color"`
}
TagItem struct {
TagCore
Category CategoryCore `json:"category"`
}
TagFull struct {
TagCore
Category CategoryCore `json:"category"`
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
UsedIncl int `json:"usedIncl"`
UsedExcl int `json:"usedExcl"`
}
)
type Autotag struct {
TriggerTag TagCore `json:"triggerTag"`
AddTag TagCore `json:"addTag"`
IsActive bool `json:"isActive"`
}
type (
PoolCore struct {
ID string `json:"id"`
Name string `json:"name"`
}
PoolItem struct {
PoolCore
}
PoolFull struct {
PoolCore
CreatedAt time.Time `json:"createdAt"`
Creator User `json:"creator"`
Notes *string `json:"notes"`
Viewed int `json:"viewed"`
}
)
type Session struct {
ID int `json:"id"`
UserAgent string `json:"userAgent"`
StartedAt time.Time `json:"startedAt"`
ExpiresAt time.Time `json:"expiresAt"`
LastActivity time.Time `json:"lastActivity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
+65
View File
@@ -0,0 +1,65 @@
package domain
import "fmt"
type ErrorCode string
const (
// File errors
ErrCodeFileNotFound ErrorCode = "FILE_NOT_FOUND"
ErrCodeMIMENotSupported ErrorCode = "MIME_NOT_SUPPORTED"
// Tag errors
ErrCodeTagNotFound ErrorCode = "TAG_NOT_FOUND"
// General errors
ErrCodeBadRequest ErrorCode = "BAD_REQUEST"
ErrCodeInternal ErrorCode = "INTERNAL_SERVER_ERROR"
)
type DomainError struct {
Err error `json:"-"`
Code ErrorCode `json:"code"`
Message string `json:"message"`
Details []any `json:"-"`
}
func (e *DomainError) Wrap(err error) *DomainError {
e.Err = err
return e
}
func NewErrorFileNotFound(file_id string) *DomainError {
return &DomainError{
Code: ErrCodeFileNotFound,
Message: fmt.Sprintf("File not found: %q", file_id),
}
}
func NewErrorMIMENotSupported(mime string) *DomainError {
return &DomainError{
Code: ErrCodeMIMENotSupported,
Message: fmt.Sprintf("MIME not supported: %q", mime),
}
}
func NewErrorTagNotFound(tag_id string) *DomainError {
return &DomainError{
Code: ErrCodeTagNotFound,
Message: fmt.Sprintf("Tag not found: %q", tag_id),
}
}
func NewErrorBadRequest(message string) *DomainError {
return &DomainError{
Code: ErrCodeBadRequest,
Message: message,
}
}
func NewErrorUnexpected() *DomainError {
return &DomainError{
Code: ErrCodeInternal,
Message: "An unexpected error occured",
}
}
+17
View File
@@ -0,0 +1,17 @@
package domain
import (
"context"
"encoding/json"
"time"
)
type FileRepository interface {
GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *DomainError)
GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files Slice[FileItem], domainErr *DomainError)
Get(ctx context.Context, user_id int, file_id string) (file FileFull, domainErr *DomainError)
Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file FileCore, domainErr *DomainError)
Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *DomainError)
Delete(ctx context.Context, file_id string) (domainErr *DomainError)
GetTags(ctx context.Context, user_id int, file_id string) (tags []TagItem, domainErr *DomainError)
}
@@ -0,0 +1,54 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/internal/domain"
)
// Initialize PostgreSQL database driver
func New(dbURL string) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(dbURL)
if err != nil {
return nil, fmt.Errorf("failed to parse connection string: %w", err)
}
poolConfig.MaxConns = 100
poolConfig.MinConns = 0
poolConfig.MaxConnLifetime = time.Hour
poolConfig.HealthCheckPeriod = 30 * time.Second
ctx := context.Background()
db, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("failed to initialize DB connections pool: %w", err)
}
if err = db.Ping(ctx); err != nil {
return nil, fmt.Errorf("failed to ping database: %w", err)
}
return db, nil
}
// Transaction wrapper
func transaction(ctx context.Context, db *pgxpool.Pool, handler func(context.Context, pgx.Tx) *domain.DomainError) (domainErr *domain.DomainError) {
tx, err := db.Begin(ctx)
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
domainErr = handler(ctx, tx)
if domainErr != nil {
tx.Rollback(ctx)
return
}
err = tx.Commit(ctx)
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
}
return
}
@@ -0,0 +1,331 @@
package postgres
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"tanabata/internal/domain"
)
type FileRepository struct {
db *pgxpool.Pool
}
func NewFileRepository(db *pgxpool.Pool) *FileRepository {
return &FileRepository{db: db}
}
// Get user permissions on file
func (s *FileRepository) GetAccess(ctx context.Context, user_id int, file_id string) (canView, canEdit bool, domainErr *domain.DomainError) {
row := s.db.QueryRow(ctx, `
SELECT
COALESCE(a.view, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE),
COALESCE(a.edit, FALSE) OR f.creator_id=$1 OR COALESCE(u.is_admin, FALSE)
FROM data.files f
LEFT JOIN acl.files a ON a.file_id=f.id AND a.user_id=$1
LEFT JOIN system.users u ON u.id=$1
WHERE f.id=$2
`, user_id, file_id)
err := row.Scan(&canView, &canEdit)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "22P02":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
}
return
}
// Get a set of files
func (s *FileRepository) GetSlice(ctx context.Context, user_id int, filter, sort string, limit, offset int) (files domain.Slice[domain.FileItem], domainErr *domain.DomainError) {
filterCond, err := filterToSQL(filter)
if err != nil {
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid filter string: %q", filter)).Wrap(err)
return
}
sortExpr, err := sortToSQL(sort)
if err != nil {
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting parameter: %q", sort)).Wrap(err)
return
}
// prepare query
query := `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE f.is_deleted IS FALSE AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=f.id AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1)) AND
`
query += filterCond
queryCount := query
query += sortExpr
if limit >= 0 {
query += fmt.Sprintf(" LIMIT %d", limit)
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET %d", offset)
}
// execute query
domainErr = transaction(ctx, s.db, func(ctx context.Context, tx pgx.Tx) (domainErr *domain.DomainError) {
rows, err := tx.Query(ctx, query, user_id)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "42P10":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid sorting field: %q", sort[1:])).Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
defer rows.Close()
count := 0
for rows.Next() {
var file domain.FileItem
err = rows.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin)
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
files.Data = append(files.Data, file)
count++
}
err = rows.Err()
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
files.Pagination.Limit = limit
files.Pagination.Offset = offset
files.Pagination.Count = count
row := tx.QueryRow(ctx, fmt.Sprintf("SELECT COUNT(*) FROM (%s) tmp", queryCount), user_id)
err = row.Scan(&files.Pagination.Total)
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
}
return
})
return
}
// Get file
func (s *FileRepository) Get(ctx context.Context, user_id int, file_id string) (file domain.FileFull, domainErr *domain.DomainError) {
row := s.db.QueryRow(ctx, `
SELECT
f.id,
f.name,
m.name,
m.extension,
uuid_extract_timestamp(f.id),
u.name,
u.is_admin,
f.notes,
f.metadata,
(SELECT COUNT(*) FROM activity.file_views fv WHERE fv.file_id=$2 AND fv.user_id=$1)
FROM data.files f
JOIN system.mime m ON m.id=f.mime_id
JOIN system.users u ON u.id=f.creator_id
WHERE f.is_deleted IS FALSE
`, user_id, file_id)
err := row.Scan(&file.ID, &file.Name, &file.MIME.Name, &file.MIME.Extension, &file.CreatedAt, &file.Creator.Name, &file.Creator.IsAdmin, &file.Notes, &file.Metadata, &file.Viewed)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
return
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "22P02":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
return
}
// Add file
func (s *FileRepository) Add(ctx context.Context, user_id int, name, mime string, datetime time.Time, notes string, metadata json.RawMessage) (file domain.FileCore, domainErr *domain.DomainError) {
var mime_id int
var extension string
row := s.db.QueryRow(ctx, "SELECT id, extension FROM system.mime WHERE name=$1", mime)
err := row.Scan(&mime_id, &extension)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
domainErr = domain.NewErrorMIMENotSupported(mime).Wrap(err)
return
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
row = s.db.QueryRow(ctx, `
INSERT INTO data.files (name, mime_id, datetime, creator_id, notes, metadata)
VALUES (NULLIF($1, ''), $2, $3, $4, NULLIF($5 ,''), $6)
RETURNING id
`, name, mime_id, datetime, user_id, notes, metadata)
err = row.Scan(&file.ID)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "22007":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", datetime)).Wrap(err)
return
case "23502":
domainErr = domain.NewErrorBadRequest("Unable to set NULL to some fields").Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
file.Name = &name
file.MIME.Name = mime
file.MIME.Extension = extension
return
}
// Update file
func (s *FileRepository) Update(ctx context.Context, file_id string, updates map[string]interface{}) (domainErr *domain.DomainError) {
if len(updates) == 0 {
// domainErr = domain.NewErrorBadRequest(nil, "No fields provided for update")
return
}
query := "UPDATE data.files SET"
newValues := []interface{}{file_id}
count := 2
for field, value := range updates {
switch field {
case "name", "notes":
query += fmt.Sprintf(" %s=NULLIF($%d, '')", field, count)
case "datetime":
query += fmt.Sprintf(" %s=NULLIF($%d, '')::timestamptz", field, count)
case "metadata":
query += fmt.Sprintf(" %s=NULLIF($%d, '')::jsonb", field, count)
default:
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Unknown field: %q", field))
return
}
newValues = append(newValues, value)
count++
}
query += fmt.Sprintf(" WHERE id=$1 AND is_deleted IS FALSE")
commandTag, err := s.db.Exec(ctx, query, newValues...)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "22P02":
domainErr = domain.NewErrorBadRequest("Invalid format of some values").Wrap(err)
return
case "22007":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid datetime: %q", updates["datetime"])).Wrap(err)
return
case "23502":
domainErr = domain.NewErrorBadRequest("Some fields cannot be empty").Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
if commandTag.RowsAffected() == 0 {
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
return
}
return
}
// Delete file
func (s *FileRepository) Delete(ctx context.Context, file_id string) (domainErr *domain.DomainError) {
commandTag, err := s.db.Exec(ctx,
"UPDATE data.files SET is_deleted=true WHERE id=$1 AND is_deleted IS FALSE",
file_id)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "22P02":
domainErr = domain.NewErrorBadRequest(fmt.Sprintf("Invalid file id: %q", file_id)).Wrap(err)
return
}
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
if commandTag.RowsAffected() == 0 {
domainErr = domain.NewErrorFileNotFound(file_id).Wrap(err)
return
}
return
}
// Get list of tags of file
func (s *FileRepository) GetTags(ctx context.Context, user_id int, file_id string) (tags []domain.TagItem, domainErr *domain.DomainError) {
rows, err := s.db.Query(ctx, `
SELECT
t.id,
t.name,
t.color,
c.id,
c.name,
c.color
FROM data.tags t
LEFT JOIN data.categories c ON c.id=t.category_id
JOIN data.file_tag ft ON ft.tag_id=t.id AND ft.file_id=$2
JOIN data.files f ON f.id=$2
WHERE NOT f.is_deleted AND (f.creator_id=$1 OR (SELECT view FROM acl.files WHERE file_id=$2 AND user_id=$1) OR (SELECT is_admin FROM system.users WHERE id=$1))
`, user_id, file_id)
if err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && (pgErr.Code == "22P02" || pgErr.Code == "22007") {
domainErr = domain.NewErrorBadRequest(pgErr.Message).Wrap(err)
return
}
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
defer rows.Close()
for rows.Next() {
var tag domain.TagItem
err = rows.Scan(&tag.ID, &tag.Name, &tag.Color, &tag.Category.ID, &tag.Category.Name, &tag.Category.Color)
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
return
}
tags = append(tags, tag)
}
err = rows.Err()
if err != nil {
domainErr = domain.NewErrorUnexpected().Wrap(err)
}
return
}
@@ -1,21 +1,52 @@
package db
package postgres
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
)
// Handle database error
func handleDBError(errIn error) (statusCode int, err error) {
if errIn == nil {
statusCode = http.StatusOK
return
}
if errors.Is(errIn, pgx.ErrNoRows) {
err = fmt.Errorf("not found")
statusCode = http.StatusNotFound
return
}
var pgErr *pgconn.PgError
if errors.As(errIn, &pgErr) {
switch pgErr.Code {
case "22P02", "22007": // Invalid data format
err = fmt.Errorf("%s", pgErr.Message)
statusCode = http.StatusBadRequest
return
case "23505": // Unique constraint violation
err = fmt.Errorf("already exists")
statusCode = http.StatusConflict
return
}
}
return http.StatusInternalServerError, errIn
}
// Convert "filter" URL param to SQL "WHERE" condition
func filterToSQL(filter string) (sql string, statusCode int, err error) {
func filterToSQL(filter string) (sql string, err error) {
// filterTokens := strings.Split(string(filter), ";")
sql = "(true)"
return
}
// Convert "sort" URL param to SQL "ORDER BY"
func sortToSQL(sort string) (sql string, statusCode int, err error) {
func sortToSQL(sort string) (sql string, err error) {
if sort == "" {
return
}
@@ -32,7 +63,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
sortOrder = "DESC"
default:
err = fmt.Errorf("invalid sorting order mark: %q", sortOrder)
statusCode = http.StatusBadRequest
return
}
// validate sorting column
@@ -40,7 +70,6 @@ func sortToSQL(sort string) (sql string, statusCode int, err error) {
n, err = strconv.Atoi(sortColumn)
if err != nil || n < 0 {
err = fmt.Errorf("invalid sorting column: %q", sortColumn)
statusCode = http.StatusBadRequest
return
}
// add sorting option to query
@@ -0,0 +1,43 @@
package rest
import (
"net/http"
"tanabata/internal/domain"
)
type ErrorResponse struct {
Error string `json:"error"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
}
type ErrorMapper struct{}
func (m *ErrorMapper) MapError(err domain.DomainError) (int, ErrorResponse) {
switch err.Code {
case domain.ErrCodeFileNotFound:
return http.StatusNotFound, ErrorResponse{
Error: "Not Found",
Code: string(err.Code),
Message: err.Message,
}
case domain.ErrCodeMIMENotSupported:
return http.StatusNotFound, ErrorResponse{
Error: "MIME not supported",
Code: string(err.Code),
Message: err.Message,
}
case domain.ErrCodeBadRequest:
return http.StatusNotFound, ErrorResponse{
Error: "Bad Request",
Code: string(err.Code),
Message: err.Message,
}
}
return http.StatusInternalServerError, ErrorResponse{
Error: "Internal Server Error",
Code: string(err.Code),
Message: err.Message,
}
}
-75
View File
@@ -1,75 +0,0 @@
package models
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
type User struct {
Name string `json:"name"`
IsAdmin bool `json:"is_admin"`
}
type MIME struct {
Name string `json:"name"`
Extension string `json:"extension"`
}
type Category struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type File struct {
ID string `json:"id"`
Name pgtype.Text `json:"name"`
MIME MIME `json:"mime"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
Color pgtype.Text `json:"color"`
Category Category `json:"category"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Autotag struct {
TriggerTag Tag `json:"trigger_tag"`
AddTag Tag `json:"add_tag"`
IsActive bool `json:"is_active"`
}
type Pool struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
Creator User `json:"creator"`
}
type Session struct {
ID int `json:"id"`
UserAgent string `json:"user_agent"`
StartedAt time.Time `json:"started_at"`
ExpiresAt time.Time `json:"expires_at"`
LastActivity time.Time `json:"last_activity"`
}
type Pagination struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Count int `json:"count"`
}
type Slice[T any] struct {
Pagination Pagination `json:"pagination"`
Data []T `json:"data"`
}
+48 -3
View File
@@ -80,6 +80,49 @@ CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA public;
COMMENT ON EXTENSION "uuid-ossp" IS 'generate universally unique identifiers (UUIDs)';
--
-- Name: add_file_to_tag_recursive(uuid, uuid); Type: FUNCTION; Schema: data; Owner: -
--
CREATE FUNCTION data.add_file_to_tag_recursive(f_id uuid, t_id uuid) RETURNS SETOF uuid
LANGUAGE plpgsql
AS $$
DECLARE
tmp uuid;
tt_id uuid;
ttt_id uuid;
BEGIN
INSERT INTO data.file_tag VALUES (f_id, t_id) ON CONFLICT DO NOTHING RETURNING tag_id INTO tmp;
IF tmp IS NULL THEN
RETURN;
END IF;
RETURN NEXT t_id;
FOR tt_id IN
SELECT a.add_tag_id FROM data.autotags a WHERE a.trigger_tag_id=t_id AND a.is_active
LOOP
FOR ttt_id IN SELECT data.add_file_to_tag_recursive(f_id, tt_id)
LOOP
RETURN NEXT ttt_id;
END LOOP;
END LOOP;
END;
$$;
--
-- Name: uuid_extract_timestamp(uuid); Type: FUNCTION; Schema: public; Owner: -
--
CREATE FUNCTION public.uuid_extract_timestamp(uuid_val uuid) RETURNS timestamp with time zone
LANGUAGE sql IMMUTABLE
AS $$
SELECT to_timestamp(
('x' || LEFT(REPLACE(uuid_val::TEXT, '-', ''), 12))::BIT(48)::BIGINT
/ 1000.0
);
$$;
--
-- Name: uuid_v7(timestamp with time zone); Type: FUNCTION; Schema: public; Owner: -
--
@@ -253,7 +296,7 @@ CREATE TABLE data.autotags (
CREATE TABLE data.categories (
id uuid DEFAULT public.uuid_v7() NOT NULL,
name character varying(256) NOT NULL,
notes text,
notes text DEFAULT ''::text NOT NULL,
color character(6),
creator_id smallint NOT NULL
);
@@ -291,7 +334,8 @@ CREATE TABLE data.files (
datetime timestamp with time zone DEFAULT clock_timestamp() NOT NULL,
notes text,
metadata jsonb NOT NULL,
creator_id smallint NOT NULL
creator_id smallint NOT NULL,
is_deleted boolean DEFAULT false NOT NULL
);
@@ -360,7 +404,8 @@ CREATE TABLE system.users (
id smallint NOT NULL,
name character varying(32) NOT NULL,
password text NOT NULL,
is_admin boolean DEFAULT false NOT NULL
is_admin boolean DEFAULT false NOT NULL,
can_create boolean DEFAULT false NOT NULL
);
+212 -210
View File
@@ -1,210 +1,212 @@
@startuml Tanabata File Manager entity relationship diagram
' skinparam linetype ortho
' ========== SYSTEM ==========
entity "system.users" as usr {
* id : smallserial <<generated>>
--
* name : varchar(32)
* password : text
* is_admin : boolean
}
entity "system.mime" as mime {
* id : smallserial <<generated>>
--
* name : varchar(127)
* extension : varchar(16)
}
' ========== DATA ==========
entity "data.categories" as cty {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
cty::creator_id }o--|| usr::id
entity "data.files" as fle {
* id : uuid <<generated>>
--
name : varchar(256)
* mime_id : smallint
* datetime : timestamptz
notes : text
* metadata : jsonb
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
fle::mime_id }o--|| mime::id
fle::creator_id }o--|| usr::id
entity "data.tags" as tag {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
category_id : uuid
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
tag::category_id }o--o| cty::id
tag::creator_id }o--|| usr::id
entity "data.file_tag" as ft {
* file_id : uuid
* tag_id : uuid
}
ft::file_id }o--|| fle::id
ft::tag_id }o--|| tag::id
entity "data.autotags" as atg {
* trigger_tag_id : uuid
* add_tag_id : uuid
--
* is_active : boolean
}
atg::trigger_tag_id }o--|| tag::id
atg::add_tag_id }o--|| tag::id
entity "data.pools" as pool {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
' parent_id : uuid
' * created_at : timestamptz
* creator_id : smallint
' * is_private : boolean
}
pool::creator_id }o--|| usr::id
' pool::parent_id }o--o| pool::id
entity "data.file_pool" as fp {
* file_id : uuid
* pool_id : uuid
* number : smallint
}
fp::file_id }o--|| fle::id
fp::pool_id }o--|| pool::id
' ========== ACL ==========
entity "acl.files" as acl_f {
* user_id : smallint
* file_id : uuid
--
* view : boolean
* edit : boolean
}
acl_f::user_id }o--|| usr::id
acl_f::file_id }o--|| fle::id
entity "acl.tags" as acl_t {
* user_id : smallint
* tag_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_t::user_id }o--|| usr::id
acl_t::tag_id }o--|| tag::id
entity "acl.categories" as acl_c {
* user_id : smallint
* category_id : uuid
--
* view : boolean
* edit : boolean
' * tags_view : boolean
' * tags_edit : boolean
}
acl_c::user_id }o--|| usr::id
acl_c::category_id }o--|| cty::id
entity "acl.pools" as acl_p {
* user_id : smallint
* pool_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_p::user_id }o--|| usr::id
acl_p::pool_id }o--|| pool::id
' ========== ACTIVITY ==========
entity "activity.sessions" as ssn {
* id : serial <<generated>>
--
* token : text
* user_id : smallint
* user_agent : varchar(512)
* started_at : timestamptz
expires_at : timestamptz
* last_activity : timestamptz
}
ssn::user_id }o--|| usr::id
entity "activity.file_views" as fv {
* file_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
fv::file_id }o--|| fle::id
fv::user_id }o--|| usr::id
entity "activity.tag_uses" as tu {
* tag_id : uuid
* timestamp : timestamptz
* user_id : smallint
--
* included : boolean
}
tu::tag_id }o--|| tag::id
tu::user_id }o--|| usr::id
entity "activity.pool_views" as pv {
* pool_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
pv::pool_id }o--|| pool::id
pv::user_id }o--|| usr::id
@enduml
@startuml Tanabata File Manager entity relationship diagram
' skinparam linetype ortho
' ========== SYSTEM ==========
entity "system.users" as usr {
* id : smallserial <<generated>>
--
* name : varchar(32)
* password : text
* is_admin : boolean
* can_create : boolean
}
entity "system.mime" as mime {
* id : smallserial <<generated>>
--
* name : varchar(127)
* extension : varchar(16)
}
' ========== DATA ==========
entity "data.categories" as cty {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
cty::creator_id }o--|| usr::id
entity "data.files" as fle {
* id : uuid <<generated>>
--
name : varchar(256)
* mime_id : smallint
* datetime : timestamptz
notes : text
* metadata : jsonb
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
* is_deleted : boolean
}
fle::mime_id }o--|| mime::id
fle::creator_id }o--|| usr::id
entity "data.tags" as tag {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
color : char(6)
category_id : uuid
' * created_at : timestamptz <<generated>>
* creator_id : smallint
' * is_private : boolean
}
tag::category_id }o--o| cty::id
tag::creator_id }o--|| usr::id
entity "data.file_tag" as ft {
* file_id : uuid
* tag_id : uuid
}
ft::file_id }o--|| fle::id
ft::tag_id }o--|| tag::id
entity "data.autotags" as atg {
* trigger_tag_id : uuid
* add_tag_id : uuid
--
* is_active : boolean
}
atg::trigger_tag_id }o--|| tag::id
atg::add_tag_id }o--|| tag::id
entity "data.pools" as pool {
* id : uuid <<generated>>
--
* name : varchar(256)
notes : text
' parent_id : uuid
' * created_at : timestamptz
* creator_id : smallint
' * is_private : boolean
}
pool::creator_id }o--|| usr::id
' pool::parent_id }o--o| pool::id
entity "data.file_pool" as fp {
* file_id : uuid
* pool_id : uuid
* number : smallint
}
fp::file_id }o--|| fle::id
fp::pool_id }o--|| pool::id
' ========== ACL ==========
entity "acl.files" as acl_f {
* user_id : smallint
* file_id : uuid
--
* view : boolean
* edit : boolean
}
acl_f::user_id }o--|| usr::id
acl_f::file_id }o--|| fle::id
entity "acl.tags" as acl_t {
* user_id : smallint
* tag_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_t::user_id }o--|| usr::id
acl_t::tag_id }o--|| tag::id
entity "acl.categories" as acl_c {
* user_id : smallint
* category_id : uuid
--
* view : boolean
* edit : boolean
' * tags_view : boolean
' * tags_edit : boolean
}
acl_c::user_id }o--|| usr::id
acl_c::category_id }o--|| cty::id
entity "acl.pools" as acl_p {
* user_id : smallint
* pool_id : uuid
--
* view : boolean
* edit : boolean
' * files_view : boolean
' * files_edit : boolean
}
acl_p::user_id }o--|| usr::id
acl_p::pool_id }o--|| pool::id
' ========== ACTIVITY ==========
entity "activity.sessions" as ssn {
* id : serial <<generated>>
--
* token : text
* user_id : smallint
* user_agent : varchar(512)
* started_at : timestamptz
expires_at : timestamptz
* last_activity : timestamptz
}
ssn::user_id }o--|| usr::id
entity "activity.file_views" as fv {
* file_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
fv::file_id }o--|| fle::id
fv::user_id }o--|| usr::id
entity "activity.tag_uses" as tu {
* tag_id : uuid
* timestamp : timestamptz
* user_id : smallint
--
* included : boolean
}
tu::tag_id }o--|| tag::id
tu::user_id }o--|| usr::id
entity "activity.pool_views" as pv {
* pool_id : uuid
* timestamp : timestamptz
* user_id : smallint
}
pv::pool_id }o--|| pool::id
pv::user_id }o--|| usr::id
@enduml