Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 0 additions & 45 deletions docs/api/common.md

This file was deleted.

1 change: 1 addition & 0 deletions migrations/000008_add_event_photo.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DROP TABLE IF EXISTS public.event_photo;
9 changes: 9 additions & 0 deletions migrations/000008_add_event_photo.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS public.event_photo (
photo_id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
event_id bigint NOT NULL,
object_key character varying(255) NOT NULL,
member_id character(10) NOT NULL,
gmt_create timestamp with time zone NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_event_photo_event_id ON public.event_photo (event_id);
5 changes: 0 additions & 5 deletions model/dto/common.go

This file was deleted.

10 changes: 10 additions & 0 deletions model/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type Event struct {
Status string `json:"status"`
Size string `json:"size"`
Logs []EventLog `json:"logs"`
Photos []EventPhoto `json:"photos" db:"-"`
GmtCreate string `json:"gmtCreate" db:"gmt_create"`
GmtModified string `json:"gmtModified" db:"gmt_modified"`
}
Expand Down Expand Up @@ -57,6 +58,15 @@ type EventLog struct {
GmtCreate string `json:"gmtCreate" db:"gmt_create"`
}

type EventPhoto struct {
PhotoId int64 `json:"photoId" db:"photo_id"`
EventId int64 `json:"-" db:"event_id"`
ObjectKey string `json:"-" db:"object_key"`
Url string `json:"url" db:"-"`
MemberId string `json:"memberId" db:"member_id"`
GmtCreate string `json:"gmtCreate" db:"gmt_create"`
}

type EventActionRelation struct {
EventLogId int64 `json:"event_log_id"`
EventActionId int64 `json:"event_action_id"`
Expand Down
44 changes: 44 additions & 0 deletions repo/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ func getLogStatement() squirrel.SelectBuilder {
return sq.Select(EventLogFields...).From("event_log_view")
}

func getPhotoStatement() squirrel.SelectBuilder {
return sq.Select("photo_id", "event_id", "object_key", "member_id", "gmt_create").From("event_photo").OrderBy("photo_id")
}

/*
when a struct contains sub struct, the struct's field has the db tag of "struct'dbTag.subStruct.dbTag"
for example: the db tag here for event is event, the db tag for mode.Event has field "EventId" with db tag "event_id"
Expand Down Expand Up @@ -343,3 +347,43 @@ func GetEventClientId(eventId int64) (int64, error) {
}
return clientId, nil
}

func GetEventPhotos(eventId int64) ([]model.EventPhoto, error) {
sql, args, _ := getPhotoStatement().Where(squirrel.Eq{"event_id": eventId}).ToSql()
photos := []model.EventPhoto{}
err := db.Select(&photos, sql, args...)
return photos, err
}

func CreateEventPhotos(eventId int64, photos []model.EventPhoto, check func(status string, count int) error) error {
tx, err := db.Beginx()
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.Exec("SELECT 1 FROM event WHERE event_id = $1 FOR UPDATE", eventId); err != nil {
return err
}
var current struct {
Status string `db:"status"`
Count int `db:"count"`
}
if err := tx.Get(&current, "SELECT status, (SELECT count(*) FROM event_photo WHERE event_id = $1) AS count FROM event_view WHERE event_id = $1", eventId); err != nil {
return err
}
if err := check(current.Status, current.Count); err != nil {
return err
}
insert := sq.Insert("event_photo").Columns("event_id", "object_key", "member_id", "gmt_create")
for _, p := range photos {
insert = insert.Values(eventId, p.ObjectKey, p.MemberId, p.GmtCreate)
}
sql, args, err := insert.ToSql()
if err != nil {
return err
}
if _, err := tx.Exec(sql, args...); err != nil {
return err
}
return tx.Commit()
}
60 changes: 60 additions & 0 deletions repo/event_photo_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package repo

import (
"errors"
"os"
"sync"
"testing"
"time"

"github.com/Masterminds/squirrel"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"github.com/nbtca/saturday/model"
)

func TestCreateEventPhotosChecksCapacityUnderLock(t *testing.T) {
dsn := os.Getenv("TEST_DB_DATASOURCE")
if dsn == "" {
t.Skip("TEST_DB_DATASOURCE is not set")
}
db = sqlx.MustConnect("postgres", dsn)
sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)

var eventId int64
if err := db.Get(&eventId, `INSERT INTO event (client_id, gmt_create, gmt_modified) VALUES (1, now(), now()) RETURNING event_id`); err != nil {
t.Fatal(err)
}
db.MustExec(`INSERT INTO event_event_status_relation (event_id, event_status_id) SELECT $1, event_status_id FROM event_status WHERE status = 'accepted'`, eventId)
t.Cleanup(func() {
db.MustExec(`DELETE FROM event_photo WHERE event_id = $1`, eventId)
db.MustExec(`DELETE FROM event_event_status_relation WHERE event_id = $1`, eventId)
db.MustExec(`DELETE FROM event WHERE event_id = $1`, eventId)
})

full := errors.New("full")
batch := make([]model.EventPhoto, 15)
for i := range batch {
batch[i] = model.EventPhoto{EventId: eventId, ObjectKey: "k", MemberId: "2333333333", GmtCreate: "2026-10-15T10:00:00+08:00"}
}
check := func(status string, count int) error {
time.Sleep(50 * time.Millisecond)
if status != "accepted" || count+len(batch) > 20 {
return full
}
return nil
}

var wg sync.WaitGroup
errs := make([]error, 2)
for i := range errs {
wg.Go(func() { errs[i] = CreateEventPhotos(eventId, batch, check) })
}
wg.Wait()

var count int
db.Get(&count, `SELECT count(*) FROM event_photo WHERE event_id = $1`, eventId)
if count != 15 || (errs[0] == nil) == (errs[1] == nil) || !(errors.Is(errs[0], full) || errors.Is(errs[1], full)) {
t.Errorf("count=%d errs=%v", count, errs)
}
}
20 changes: 0 additions & 20 deletions router/common.go

This file was deleted.

63 changes: 62 additions & 1 deletion router/event.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package router
import (
"bytes"
"context"
"errors"
"fmt"
"time"

Expand Down Expand Up @@ -63,10 +64,14 @@ func (er EventRouter) GetEventById(ctx context.Context, input *GetEventByIdInput
return nil, err
}

if event.MemberId != auth.ID && event.ClientId != clientId {
if !canViewEvent(event, auth, clientId) {
return nil, huma.Error401Unauthorized("not authorized")
}

if err := service.LoadPhotos(&event); err != nil {
return nil, huma.Error500InternalServerError(err.Error())
}

return util.MakeCommonResponse(event), nil
}

Expand Down Expand Up @@ -232,6 +237,58 @@ func (EventRouter) AlterCommit(ctx context.Context, input *AlterCommitEventInput
return util.MakeCommonResponse(event), nil
}

func (EventRouter) UploadPhotos(ctx context.Context, input *UploadEventPhotosInput) (*util.CommonResponse[model.Event], error) {
files := input.RawBody.File["photos"]
if len(files) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one photo is required")
}
for _, f := range files {
if contentType := f.Header.Get("Content-Type"); contentType != "" && contentType != "image/jpeg" && contentType != "image/webp" {
return nil, huma.Error422UnprocessableEntity("expected webp or jpeg")
}
}

auth, err := middleware.AuthenticateUser(input.Authorization, "member", "admin")
if err != nil {
return nil, err
}

event, err := middleware.LoadEvent(input.EventId)
if err != nil {
return nil, err
}

if event.Photos, err = repo.GetEventPhotos(event.EventId); err != nil {
return nil, huma.Error500InternalServerError(err.Error())
}

uploads := make([]service.PhotoUpload, len(files))
for i, f := range files {
file, err := f.Open()
if err != nil {
return nil, huma.Error422UnprocessableEntity("failed to open photo")
}
defer file.Close()
uploads[i] = service.PhotoUpload{File: file, Size: f.Size}
}
if err := service.EventServiceApp.AddPhotos(&event, middleware.CreateIdentityFromAuth(auth), uploads); err != nil {
if errors.Is(err, service.ErrPhotoStorage) {
util.Logger.Error(err)
return nil, huma.Error500InternalServerError(service.ErrPhotoStorage.Error())
}
return nil, huma.Error422UnprocessableEntity(err.Error())
}

event, err = middleware.LoadEvent(input.EventId)
if err != nil {
return nil, err
}
if err := service.LoadPhotos(&event); err != nil {
return nil, huma.Error500InternalServerError(err.Error())
}
return util.MakeCommonResponse(event), nil
}

func (EventRouter) RejectCommit(ctx context.Context, input *RejectCommitEventInput) (*util.CommonResponse[model.Event], error) {
auth, err := middleware.AuthenticateUser(input.Authorization, "admin")
if err != nil {
Expand Down Expand Up @@ -390,3 +447,7 @@ func (er EventRouter) Cancel(ctx context.Context, input *CancelClientEventInput)
}

var EventRouterApp = EventRouter{}

func canViewEvent(event model.Event, auth *middleware.AuthContext, clientId int64) bool {
return auth.Role == "admin" || (auth.ID != "" && event.MemberId == auth.ID) || event.ClientId == clientId
}
34 changes: 34 additions & 0 deletions router/event_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package router

import (
"testing"

"github.com/nbtca/saturday/middleware"
"github.com/nbtca/saturday/model"
)

func TestCanViewEvent(t *testing.T) {
assigned := model.Event{MemberId: "2333333333", ClientId: 7}
open := model.Event{MemberId: "", ClientId: 7}
tests := []struct {
name string
event model.Event
auth middleware.AuthContext
clientId int64
want bool
}{
{"assigned member", assigned, middleware.AuthContext{ID: "2333333333", Role: "member"}, 1, true},
{"other member", assigned, middleware.AuthContext{ID: "1111111111", Role: "member"}, 1, false},
{"admin", assigned, middleware.AuthContext{ID: "1111111111", Role: "admin"}, 1, true},
{"owning client", assigned, middleware.AuthContext{}, 7, true},
{"other client on open event", open, middleware.AuthContext{}, 8, false},
{"other client on assigned event", assigned, middleware.AuthContext{}, 8, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := canViewEvent(tt.event, &tt.auth, tt.clientId); got != tt.want {
t.Errorf("canViewEvent() = %v, want %v", got, tt.want)
}
})
}
}
16 changes: 9 additions & 7 deletions router/input_types.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package router

import (
"mime/multipart"

"github.com/nbtca/saturday/model"
"github.com/nbtca/saturday/model/dto"
)
Expand Down Expand Up @@ -134,6 +136,13 @@ type AlterCommitEventInput struct {
}
}

type UploadEventPhotosInput struct {
MemberAuthInput
EventPathInput
// Keep file headers unopened until the handler can own and close each file.
RawBody multipart.Form
}

type RejectCommitEventInput struct {
AdminAuthInput
EventPathInput
Expand Down Expand Up @@ -201,13 +210,6 @@ type CreateTokenViaLogtoInput struct {
ClientAuthInput
}

// Upload endpoint input

type UploadFileInput struct {
AuthenticatedInput
// File upload will be handled specially
}

// Webhook inputs (these may stay as Gin handlers)

type GithubWebhookInput struct {
Expand Down
Loading
Loading