-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
2009 lines (1879 loc) · 77.6 KB
/
Copy pathmain.go
File metadata and controls
2009 lines (1879 loc) · 77.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/plugins"
"code.vikunja.io/api/pkg/user"
"github.com/ThreeDotsLabs/watermill/message"
"github.com/labstack/echo/v5"
"github.com/spf13/viper"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
// FieldConfig holds a field definition's scalar constraints. xorm serializes it
// to a JSON column (TEXT under sqlite, JSON/JSONB under mysql/postgres) — the
// same xorm:"json" mechanism api_tokens.APIPermissions uses. If the Task 1 spike
// chose text+manual, swap this tag to `xorm:"text null"` and marshal in the model
// methods (Task 7); the Go type stays the same.
type FieldConfig struct {
Required bool `json:"required,omitempty"`
Default string `json:"default,omitempty"`
Min *float64 `json:"min,omitempty"` // integer/decimal range; pointer so 0 ≠ unset
Max *float64 `json:"max,omitempty"`
IsAPIOnly bool `json:"is_api_only,omitempty"` // PRD stretch; S3 owns behavior
}
// CustomFieldDefinition is a single custom field's schema.
type CustomFieldDefinition struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
Name string `xorm:"varchar(255) not null" json:"name"`
Type string `xorm:"varchar(50) not null" json:"type"`
Description string `xorm:"varchar(500) null" json:"description,omitempty"`
FieldConfig FieldConfig `xorm:"json null" json:"field_config"`
DisplayOrder int `xorm:"int not null default 0" json:"display_order"`
Created time.Time `xorm:"created not null" json:"-"`
Updated time.Time `xorm:"updated not null" json:"-"`
}
func (CustomFieldDefinition) TableName() string { return "custom_field_definitions" }
// CustomFieldValue is one field's value on one task. S3 refines value typing and
// adds the UNIQUE(field, task) constraint and query indexes.
type CustomFieldValue struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
CustomFieldDefinitionID int64 `xorm:"bigint not null unique(field_task)" json:"custom_field_definition_id"`
TaskID int64 `xorm:"bigint not null unique(field_task) index" json:"task_id"`
Value string `xorm:"text" json:"value"`
Created time.Time `xorm:"created not null" json:"-"`
Updated time.Time `xorm:"updated not null" json:"-"`
}
func (CustomFieldValue) TableName() string { return "custom_field_values" }
// CustomFieldValueOption is one selected option of a select/multiselect value on a task.
// The label_tasks shape: a real table with its own PK, FKs to the value and the option.
type CustomFieldValueOption struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
CustomFieldValueID int64 `xorm:"bigint not null index" json:"custom_field_value_id"`
CustomFieldOptionID int64 `xorm:"bigint not null index" json:"custom_field_option_id"`
Created time.Time `xorm:"created not null" json:"-"`
}
func (CustomFieldValueOption) TableName() string { return "custom_field_value_options" }
// CustomFieldOption is one row of a select/multiselect field's option list.
type CustomFieldOption struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
CustomFieldDefinitionID int64 `xorm:"bigint not null index" json:"custom_field_definition_id"`
Value string `xorm:"varchar(255) not null" json:"value"`
Label string `xorm:"varchar(255) null" json:"label,omitempty"`
DisplayOrder int `xorm:"int not null default 0" json:"display_order"`
Created time.Time `xorm:"created not null" json:"-"`
Updated time.Time `xorm:"updated not null" json:"-"`
}
func (CustomFieldOption) TableName() string { return "custom_field_options" }
// CustomFieldProject assigns a field to a project. ProjectID 0 is the sentinel
// for "all projects"; a specific ID means that project only. The handler enforces
// that a field has either the 0-row or ≥1 specific rows, never both.
type CustomFieldProject struct {
ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"`
CustomFieldDefinitionID int64 `xorm:"bigint not null index" json:"custom_field_definition_id"`
ProjectID int64 `xorm:"bigint not null index" json:"project_id"`
Created time.Time `xorm:"created not null" json:"-"`
}
func (CustomFieldProject) TableName() string { return "custom_field_projects" }
// ── Errors (plugin-local 9000s range; web.HTTPError is unavailable to yaegi,
// so handlers translate these to echo.NewHTTPError(code, message). Upstream
// conversion: replace echo.NewHTTPError with HTTPError()/ErrCode per the host
// convention in pkg/models/error.go (doc: https://vikunja.io/docs/custom-errors/).
// The ErrCode* consts below are pre-staged for that conversion.)
const (
ErrCodeCustomFieldNameEmpty = 9001
ErrCodeCustomFieldInvalidType = 9002
ErrCodeCustomFieldOptionsForNonSelect = 9003
ErrCodeCustomFieldDuplicateOption = 9004
ErrCodeCustomFieldConstraintForType = 9005
ErrCodeCustomFieldInvalidConstraint = 9006
ErrCodeCustomFieldProjectNotFound = 9007
ErrCodeCustomFieldNotFound = 9008
ErrCodeCustomFieldGlobalConflict = 9009
)
type ErrCustomFieldNameEmpty struct{}
func (ErrCustomFieldNameEmpty) Error() string { return "custom field name must not be empty" }
type ErrCustomFieldInvalidType struct{ Type string }
func (e ErrCustomFieldInvalidType) Error() string {
return fmt.Sprintf("invalid custom field type: %s", e.Type)
}
type ErrCustomFieldOptionsForNonSelect struct{ Type string }
func (e ErrCustomFieldOptionsForNonSelect) Error() string {
return fmt.Sprintf("options are only allowed for select/multiselect, not %s", e.Type)
}
type ErrCustomFieldDuplicateOption struct{ Value string }
func (e ErrCustomFieldDuplicateOption) Error() string {
return fmt.Sprintf("duplicate option value: %s", e.Value)
}
type ErrCustomFieldConstraintForType struct{ Type, Constraint string }
func (e ErrCustomFieldConstraintForType) Error() string {
return fmt.Sprintf("constraint %s is not valid for type %s", e.Constraint, e.Type)
}
type ErrCustomFieldInvalidConstraint struct{ Detail string }
func (e ErrCustomFieldInvalidConstraint) Error() string {
return fmt.Sprintf("invalid constraint: %s", e.Detail)
}
type ErrCustomFieldProjectNotFound struct{ ID int64 }
func (e ErrCustomFieldProjectNotFound) Error() string {
return fmt.Sprintf("project %d does not exist", e.ID)
}
type ErrCustomFieldNotFound struct{ ID int64 }
func (e ErrCustomFieldNotFound) Error() string {
return fmt.Sprintf("custom field definition %d not found", e.ID)
}
// ErrCustomFieldGlobalConflict: assignment mixes the global sentinel (project_id=0)
// with specific projects, or carries the sentinel alongside specific rows.
type ErrCustomFieldGlobalConflict struct{}
func (ErrCustomFieldGlobalConflict) Error() string {
return "a field is either global (all projects) or assigned to specific projects, not both"
}
const (
ErrCodeCustomFieldValueInvalid = 9010
ErrCodeCustomFieldValueEmpty = 9011
ErrCodeCustomFieldOptionNotFound = 9012
ErrCodeCustomFieldValueAlreadyExists = 9013
ErrCodeCustomFieldValueNotFound = 9014
ErrCodeCustomFieldTaskNotFound = 9015
)
type ErrCustomFieldValueInvalid struct{ Type, Detail string }
func (e ErrCustomFieldValueInvalid) Error() string {
return fmt.Sprintf("invalid value for %s field: %s", e.Type, e.Detail)
}
type ErrCustomFieldValueEmpty struct{}
func (ErrCustomFieldValueEmpty) Error() string { return "value for a required field must not be empty" }
type ErrCustomFieldOptionNotFound struct{ Value string }
func (e ErrCustomFieldOptionNotFound) Error() string {
return fmt.Sprintf("option value %q is not a valid option for this field", e.Value)
}
type ErrCustomFieldValueAlreadyExists struct{ FieldID, TaskID int64 }
func (e ErrCustomFieldValueAlreadyExists) Error() string {
return fmt.Sprintf("custom field value already exists for field %d on task %d", e.FieldID, e.TaskID)
}
type ErrCustomFieldValueNotFound struct{ FieldID, TaskID int64 }
func (e ErrCustomFieldValueNotFound) Error() string {
return fmt.Sprintf("custom field value not found for field %d on task %d", e.FieldID, e.TaskID)
}
type ErrCustomFieldTaskNotFound struct{ ID int64 }
func (e ErrCustomFieldTaskNotFound) Error() string {
return fmt.Sprintf("task %d not found", e.ID)
}
// ── Validation (pure functions; Task 4). Tasks 7 and 8 call these before
// writing a definition or its project assignments.
var validFieldTypes = map[string]struct{}{
"text": {}, "textarea": {}, "integer": {}, "decimal": {},
"date": {}, "datetime": {}, "select": {}, "multiselect": {},
"checkbox": {}, "url": {},
}
func isSelectLike(t string) bool {
return t == "select" || t == "multiselect"
}
// validateDefinition checks the type/name/options/constraints of a definition.
// It does no DB access. project assignment is validated separately
// (validateAssignment) because that needs a session.
func validateDefinition(d *CustomFieldDefinition, options []CustomFieldOption) error {
if strings.TrimSpace(d.Name) == "" {
return ErrCustomFieldNameEmpty{}
}
if _, ok := validFieldTypes[d.Type]; !ok {
return ErrCustomFieldInvalidType{Type: d.Type}
}
if len(options) > 0 && !isSelectLike(d.Type) {
return ErrCustomFieldOptionsForNonSelect{Type: d.Type}
}
seen := map[string]struct{}{}
for _, o := range options {
if strings.TrimSpace(o.Value) == "" {
return ErrCustomFieldInvalidConstraint{Detail: "option value must not be empty"}
}
if _, dup := seen[o.Value]; dup {
return ErrCustomFieldDuplicateOption{Value: o.Value}
}
seen[o.Value] = struct{}{}
}
if (d.FieldConfig.Min != nil || d.FieldConfig.Max != nil) && !(d.Type == "integer" || d.Type == "decimal") {
return ErrCustomFieldConstraintForType{Type: d.Type, Constraint: "min/max"}
}
if d.FieldConfig.Min != nil && d.FieldConfig.Max != nil && *d.FieldConfig.Min > *d.FieldConfig.Max {
return ErrCustomFieldInvalidConstraint{Detail: "min must not exceed max"}
}
return nil
}
// validateValue coerces and validates a raw value against a field definition's type
// and constraints. No DB access. For scalar types it returns (storageString, nil, nil);
// for select-types it returns ("", nil, nil) — the option IDs are resolved separately by
// resolveOptionIDs (which needs the options slice the same way this does, but is called
// by the write handler, not here, to keep this pure). raw is the JSON-decoded value.
func validateValue(def *CustomFieldDefinition, options []CustomFieldOption, raw interface{}) (string, []int64, error) {
switch def.Type {
case "text", "textarea", "url":
v, ok := raw.(string)
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: def.Type, Detail: "must be a string"}
}
if def.FieldConfig.Required && strings.TrimSpace(v) == "" {
return "", nil, ErrCustomFieldValueEmpty{}
}
if def.Type == "url" {
u, err := url.Parse(v)
if err != nil || u.Scheme == "" {
return "", nil, ErrCustomFieldValueInvalid{Type: "url", Detail: "must be a valid URL with a scheme"}
}
}
return v, nil, nil
case "integer":
switch n := raw.(type) {
case float64: // JSON numbers arrive as float64
i := int64(n)
if float64(i) != n {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "out of range"}
}
if def.FieldConfig.Min != nil && float64(i) < *def.FieldConfig.Min {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "below min"}
}
if def.FieldConfig.Max != nil && float64(i) > *def.FieldConfig.Max {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "above max"}
}
return strconv.FormatInt(i, 10), nil, nil
case string:
i, err := strconv.ParseInt(n, 10, 64)
if err != nil {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "not a valid integer"}
}
if def.FieldConfig.Min != nil && float64(i) < *def.FieldConfig.Min {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "below min"}
}
if def.FieldConfig.Max != nil && float64(i) > *def.FieldConfig.Max {
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "above max"}
}
return strconv.FormatInt(i, 10), nil, nil
}
return "", nil, ErrCustomFieldValueInvalid{Type: "integer", Detail: "must be a number"}
case "decimal":
var f float64
switch n := raw.(type) {
case float64:
f = n
case string:
v, err := strconv.ParseFloat(n, 64)
if err != nil {
return "", nil, ErrCustomFieldValueInvalid{Type: "decimal", Detail: "not a valid number"}
}
f = v
default:
return "", nil, ErrCustomFieldValueInvalid{Type: "decimal", Detail: "must be a number"}
}
if def.FieldConfig.Min != nil && f < *def.FieldConfig.Min {
return "", nil, ErrCustomFieldValueInvalid{Type: "decimal", Detail: "below min"}
}
if def.FieldConfig.Max != nil && f > *def.FieldConfig.Max {
return "", nil, ErrCustomFieldValueInvalid{Type: "decimal", Detail: "above max"}
}
return strconv.FormatFloat(f, 'f', -1, 64), nil, nil
case "date":
s, ok := raw.(string)
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: "date", Detail: "must be an ISO date string"}
}
if _, err := time.Parse("2006-01-02", s); err != nil {
return "", nil, ErrCustomFieldValueInvalid{Type: "date", Detail: "must be YYYY-MM-DD"}
}
return s, nil, nil
case "datetime":
s, ok := raw.(string)
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: "datetime", Detail: "must be an RFC3339 string"}
}
if _, err := time.Parse(time.RFC3339, s); err != nil {
return "", nil, ErrCustomFieldValueInvalid{Type: "datetime", Detail: "must be RFC3339"}
}
return s, nil, nil
case "checkbox":
switch v := raw.(type) {
case bool:
return strconv.FormatBool(v), nil, nil
case string:
b, err := strconv.ParseBool(v)
if err != nil {
return "", nil, ErrCustomFieldValueInvalid{Type: "checkbox", Detail: "must be a boolean"}
}
return strconv.FormatBool(b), nil, nil
}
return "", nil, ErrCustomFieldValueInvalid{Type: "checkbox", Detail: "must be a boolean"}
case "select", "multiselect":
// validate the option value string(s) are in the field's current options' values.
// returns no storage string — the handler calls resolveOptionIDs to get the IDs.
validValues := map[string]struct{}{}
for _, o := range options {
validValues[o.Value] = struct{}{}
}
if def.Type == "select" {
s, ok := raw.(string)
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: "select", Detail: "must be a string option value"}
}
if def.FieldConfig.Required && s == "" {
return "", nil, ErrCustomFieldValueEmpty{}
}
if s != "" {
if _, ok := validValues[s]; !ok {
return "", nil, ErrCustomFieldOptionNotFound{Value: s}
}
}
return s, nil, nil
}
// multiselect: raw is a []interface{} of strings (JSON array)
arr, ok := raw.([]interface{})
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: "multiselect", Detail: "must be an array of option values"}
}
vals := make([]string, 0, len(arr))
for _, e := range arr {
s, ok := e.(string)
if !ok {
return "", nil, ErrCustomFieldValueInvalid{Type: "multiselect", Detail: "array elements must be strings"}
}
if _, ok := validValues[s]; !ok {
return "", nil, ErrCustomFieldOptionNotFound{Value: s}
}
vals = append(vals, s)
}
if def.FieldConfig.Required && len(vals) == 0 {
return "", nil, ErrCustomFieldValueEmpty{}
}
// join for a notional storage string (not actually stored for select-types, but
// return it for completeness; the handler uses resolveOptionIDs for the child rows)
return strings.Join(vals, "\x00"), nil, nil
}
return "", nil, ErrCustomFieldInvalidType{Type: def.Type}
}
// resolveOptionIDs maps option value strings to option IDs by matching the passed
// options slice's Value field. Called by write handlers after validateValue succeeds.
func resolveOptionIDs(options []CustomFieldOption, valueStrings []string) ([]int64, error) {
byValue := map[string]int64{}
for _, o := range options {
byValue[o.Value] = o.ID
}
ids := make([]int64, 0, len(valueStrings))
for _, v := range valueStrings {
id, ok := byValue[v]
if !ok {
return nil, ErrCustomFieldOptionNotFound{Value: v}
}
ids = append(ids, id)
}
return ids, nil
}
// validateAssignment confirms each specific project exists. The global sentinel
// (empty/nil projectIDs) needs no such check. Mixing sentinel with specific IDs
// is a client error the handler prevents before calling here.
func validateAssignment(s *xorm.Session, projectIDs []int64) error {
for _, pid := range projectIDs {
has, err := s.Table("projects").Where("id = ?", pid).Exist(&models.Project{})
if err != nil {
return fmt.Errorf("custom-fields: check project %d: %w", pid, err)
}
if !has {
return ErrCustomFieldProjectNotFound{ID: pid}
}
}
return nil
}
// ── Permissions. All field-definition management is whitelist-gated. These
// mirror Vikunja's Permissions interface (CanCreate/CanRead/CanUpdate/CanDelete);
// the only deviation is *user.User instead of web.Auth (web is unavailable to
// yaegi) — an upstream-conversion point.
func (d *CustomFieldDefinition) CanCreate(s *xorm.Session, u *user.User) (bool, error) {
return IsManager(u.Username), nil
}
func (d *CustomFieldDefinition) CanRead(s *xorm.Session, u *user.User) (bool, error) {
return IsManager(u.Username), nil
}
func (d *CustomFieldDefinition) CanUpdate(s *xorm.Session, u *user.User) (bool, error) {
return IsManager(u.Username), nil
}
func (d *CustomFieldDefinition) CanDelete(s *xorm.Session, u *user.User) (bool, error) {
return IsManager(u.Username), nil
}
// Value access is gated on task-level permission, not the management whitelist:
// a value is visible/writable exactly when its task is. These delegate to the
// host's models.Task.CanRead/CanUpdate with the same *user.User (yaegi accepts
// it in place of web.Auth). canWrite is the shared write gate (create/update/
// delete all require task write access).
func (v *CustomFieldValue) CanRead(s *xorm.Session, u *user.User) (bool, error) {
t := &models.Task{ID: v.TaskID}
ok, _, err := t.CanRead(s, u) // discard maxPermission (3-return → 2-return)
if err != nil {
// a not-found task means no access; surface as false, not 500
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "does not exist") {
return false, nil
}
return false, err
}
return ok, nil
}
func (v *CustomFieldValue) canWrite(s *xorm.Session, u *user.User) (bool, error) {
t := &models.Task{ID: v.TaskID}
ok, err := t.CanUpdate(s, u)
if err != nil {
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "does not exist") {
return false, nil
}
return false, err
}
return ok, nil
}
func (v *CustomFieldValue) CanCreate(s *xorm.Session, u *user.User) (bool, error) {
return v.canWrite(s, u)
}
func (v *CustomFieldValue) CanUpdate(s *xorm.Session, u *user.User) (bool, error) {
return v.canWrite(s, u)
}
func (v *CustomFieldValue) CanDelete(s *xorm.Session, u *user.User) (bool, error) {
return v.canWrite(s, u)
}
// whitelist holds the lowercase usernames permitted to manage custom fields.
// Populated once in Init() from Vikunja's config (customfields.whitelist,
// overridable by the VIKUNJA_CUSTOMFIELDS_WHITELIST env var); read-only
// afterward, so it needs no synchronization.
var whitelist map[string]struct{}
// loadWhitelist reads the management whitelist from Vikunja's config
// (the customfields.whitelist key, overridable by the VIKUNJA_CUSTOMFIELDS_WHITELIST
// env var) and returns a lowercase-normalized set of permitted usernames. Source
// is isolated here so a future swap to config.Key(...) is a one-function change.
//
// Malformed entries (empty after trimming, e.g. "alice,,bob") are logged and
// skipped — never fatal. An absent/empty value yields an empty set (deny-all).
func loadWhitelist() map[string]struct{} {
set := map[string]struct{}{}
raw := viper.GetString("customfields.whitelist")
if raw == "" {
log.Infof("[custom-fields] whitelist empty — no users may manage custom fields")
return set
}
for i, entry := range strings.Split(raw, ",") {
name := strings.ToLower(strings.TrimSpace(entry))
if name == "" {
log.Errorf("[custom-fields] whitelist: ignoring empty entry at position %d", i)
continue
}
set[name] = struct{}{}
}
log.Infof("[custom-fields] whitelist loaded: %d manager(s)", len(set))
return set
}
// IsManager reports whether username is on the management whitelist. It is the
// single authorization check S2 (field-definition API) and S9 (management UI)
// call before allowing field-definition changes. Deny-by-default: an empty
// whitelist denies everyone. Comparison is case-insensitive.
func IsManager(username string) bool {
if username == "" {
return false
}
_, ok := whitelist[strings.ToLower(username)]
return ok
}
// ── Model CRUD. Handlers (Task 8) own the session: open, call CanX, call these,
// commit, close. These methods never open/commit the session themselves. No events
// (deferred — see spec Events section).
// resolveProjectIDs enforces mutual exclusivity: empty ⟹ global sentinel [0];
// non-empty ⟹ those IDs. The caller must not pass both a sentinel and specifics.
func resolveProjectIDs(projectIDs []int64) []int64 {
if len(projectIDs) == 0 {
return []int64{0} // sentinel: all projects
}
return projectIDs
}
// setOptions reconciles a definition's option rows. Existing options are matched by Value
// and updated in place (preserving their IDs — critical because custom_field_value_options
// references option IDs); new options are inserted; removed options are deleted. The
// delete-existing-then-reinsert-all of S2 would orphan all stored select values on any
// option edit, even a reorder (spec: setOptions re-creation interaction).
func setOptions(s *xorm.Session, defID int64, t string, options []CustomFieldOption) error {
// only for select/multiselect
if !isSelectLike(t) {
// non-select: ensure no option rows exist
_, err := s.Table("custom_field_options").Where("custom_field_definition_id = ?", defID).Delete(&CustomFieldOption{})
return err
}
// fetch existing options keyed by value
var existing []CustomFieldOption
if err := s.Table("custom_field_options").Where("custom_field_definition_id = ?", defID).Find(&existing); err != nil {
return fmt.Errorf("custom-fields: get options for reconcile: %w", err)
}
existingByValue := map[string]*CustomFieldOption{}
for i := range existing {
existingByValue[existing[i].Value] = &existing[i]
}
seen := map[string]struct{}{}
for i := range options {
opt := options[i]
opt.ID = 0 // don't trust a client-supplied id
opt.CustomFieldDefinitionID = defID
seen[opt.Value] = struct{}{}
if e, ok := existingByValue[opt.Value]; ok {
// update in place: preserve e.ID, update label + display_order
e.Label = opt.Label
e.DisplayOrder = opt.DisplayOrder
if _, err := s.Table("custom_field_options").ID(e.ID).Cols("label", "display_order").Update(e); err != nil {
return fmt.Errorf("custom-fields: update option: %w", err)
}
} else {
// new option: insert
if _, err := s.Table("custom_field_options").Insert(&opt); err != nil {
return fmt.Errorf("custom-fields: insert option: %w", err)
}
}
}
// delete options not in the new set
for _, e := range existing {
if _, ok := seen[e.Value]; !ok {
if _, err := s.Table("custom_field_options").ID(e.ID).Delete(&CustomFieldOption{}); err != nil {
return fmt.Errorf("custom-fields: delete removed option: %w", err)
}
}
}
return nil
}
// setAssignment replaces a definition's project assignment. delete-existing is a
// no-op on Create; on Update it clears the old set before re-inserting.
func setAssignment(s *xorm.Session, defID int64, projectIDs []int64) error {
if _, err := s.Table("custom_field_projects").Where("custom_field_definition_id = ?", defID).Delete(&CustomFieldProject{}); err != nil {
return fmt.Errorf("custom-fields: clear assignment: %w", err)
}
assign := resolveProjectIDs(projectIDs)
rows := make([]CustomFieldProject, len(assign))
for i, pid := range assign {
rows[i] = CustomFieldProject{CustomFieldDefinitionID: defID, ProjectID: pid}
}
if _, err := s.Table("custom_field_projects").Insert(&rows); err != nil {
return fmt.Errorf("custom-fields: insert assignment: %w", err)
}
return nil
}
func (d *CustomFieldDefinition) Create(s *xorm.Session, u *user.User, options []CustomFieldOption, projectIDs []int64) (*CustomFieldDefinition, error) {
if err := validateDefinition(d, options); err != nil {
return nil, err
}
if err := validateAssignment(s, projectIDs); err != nil {
return nil, err
}
if _, err := s.Table("custom_field_definitions").Insert(d); err != nil {
return nil, fmt.Errorf("custom-fields: insert definition: %w", err)
}
if err := setOptions(s, d.ID, d.Type, options); err != nil {
return nil, err
}
if err := setAssignment(s, d.ID, projectIDs); err != nil {
return nil, err
}
return d, nil
}
// ReadOne fetches a definition with its options and project assignment. Returns
// the definition, its options (empty for non-select), and its project_ids (empty
// slice if global — callers treat empty as "all projects").
func (d *CustomFieldDefinition) ReadOne(s *xorm.Session) (*CustomFieldDefinition, []CustomFieldOption, []int64, error) {
has, err := s.Table("custom_field_definitions").ID(d.ID).Get(d)
if err != nil {
return nil, nil, nil, fmt.Errorf("custom-fields: get definition: %w", err)
}
if !has {
return nil, nil, nil, ErrCustomFieldNotFound{ID: d.ID}
}
var opts []CustomFieldOption
if err := s.Table("custom_field_options").Where("custom_field_definition_id = ?", d.ID).OrderBy("display_order asc").Find(&opts); err != nil {
return nil, nil, nil, fmt.Errorf("custom-fields: get options: %w", err)
}
var assigns []CustomFieldProject
if err := s.Table("custom_field_projects").Where("custom_field_definition_id = ?", d.ID).Find(&assigns); err != nil {
return nil, nil, nil, fmt.Errorf("custom-fields: get assignment: %w", err)
}
pids := make([]int64, 0, len(assigns))
for _, a := range assigns {
if a.ProjectID != 0 { // omit the global sentinel from the response list
pids = append(pids, a.ProjectID)
}
}
return d, opts, pids, nil
}
// ReadAll lists all definitions. If projectID > 0, filters to fields that apply
// to that project (global sentinel OR a row for that project).
func ReadAll(s *xorm.Session, projectID int64) ([]CustomFieldDefinition, error) {
var defs []CustomFieldDefinition
if projectID == 0 {
if err := s.Table("custom_field_definitions").OrderBy("display_order asc").Find(&defs); err != nil {
return nil, fmt.Errorf("custom-fields: list definitions: %w", err)
}
return defs, nil
}
// Fields applying to projectID: those with a custom_field_projects row where
// project_id = projectID OR project_id = 0 (global).
subQuery := "(SELECT DISTINCT custom_field_definition_id FROM custom_field_projects WHERE project_id = ? OR project_id = 0)"
if err := s.Table("custom_field_definitions").Where("id IN "+subQuery, projectID).OrderBy("display_order asc").Find(&defs); err != nil {
return nil, fmt.Errorf("custom-fields: list definitions by project: %w", err)
}
return defs, nil
}
// Update replaces the definition and its options + assignment wholesale (PUT
// full-replace). It does NOT touch custom_field_values (S3's table). No event
// (deferred). The handler captures no `old` state.
func (d *CustomFieldDefinition) Update(s *xorm.Session, u *user.User, options []CustomFieldOption, projectIDs []int64) (*CustomFieldDefinition, error) {
has, err := s.Table("custom_field_definitions").ID(d.ID).Exist(&CustomFieldDefinition{})
if err != nil {
return nil, fmt.Errorf("custom-fields: check definition: %w", err)
}
if !has {
return nil, ErrCustomFieldNotFound{ID: d.ID}
}
if err := validateDefinition(d, options); err != nil {
return nil, err
}
if err := validateAssignment(s, projectIDs); err != nil {
return nil, err
}
// AllCols writes every column including zero values (xorm's Update skips
// zero-valued cols by default, which would break PUT full-replace for
// cleared fields, display_order=0, field_config.required=false, etc.).
// (Mirrors upstream label.go's explicit .Cols(...) approach.)
if _, err := s.Table("custom_field_definitions").ID(d.ID).AllCols().UseBool().Update(d); err != nil {
return nil, fmt.Errorf("custom-fields: update definition: %w", err)
}
if err := setOptions(s, d.ID, d.Type, options); err != nil {
return nil, err
}
if err := setAssignment(s, d.ID, projectIDs); err != nil {
return nil, err
}
return d, nil
}
// Delete hard-cascades the definition's OWN rows: values (S3, two-step via the
// value-id subquery) + definition options + assignment + the definition row.
// No event (deferred).
func (d *CustomFieldDefinition) Delete(s *xorm.Session) error {
has, err := s.Table("custom_field_definitions").ID(d.ID).Exist(&CustomFieldDefinition{})
if err != nil {
return fmt.Errorf("custom-fields: check definition: %w", err)
}
if !has {
return ErrCustomFieldNotFound{ID: d.ID}
}
// cascade-delete values (two-step: child rows via the value-id subquery, then values)
if _, err := s.Table("custom_field_value_options").
Where("custom_field_value_id IN (SELECT id FROM custom_field_values WHERE custom_field_definition_id = ?)", d.ID).
Delete(&CustomFieldValueOption{}); err != nil {
return fmt.Errorf("custom-fields: cascade-delete value options: %w", err)
}
if _, err := s.Table("custom_field_values").
Where("custom_field_definition_id = ?", d.ID).
Delete(&CustomFieldValue{}); err != nil {
return fmt.Errorf("custom-fields: cascade-delete values: %w", err)
}
if _, err := s.Table("custom_field_options").Where("custom_field_definition_id = ?", d.ID).Delete(&CustomFieldOption{}); err != nil {
return fmt.Errorf("custom-fields: delete options: %w", err)
}
if _, err := s.Table("custom_field_projects").Where("custom_field_definition_id = ?", d.ID).Delete(&CustomFieldProject{}); err != nil {
return fmt.Errorf("custom-fields: delete assignment: %w", err)
}
if _, err := s.Table("custom_field_definitions").ID(d.ID).Delete(&CustomFieldDefinition{}); err != nil {
return fmt.Errorf("custom-fields: delete definition: %w", err)
}
return nil
}
// ── Handlers. Thin: parse → CanX (403) → model → commit → JSON map. No events
// (deferred). web.HTTPError is unavailable, so handlers use echo.NewHTTPError.
//
// R7 (spike 1 caveat): interpreted structs serialize as {} through c.JSON, so
// responses are built as maps field-by-field, never by echoing a struct. xorm
// DB read/write of interpreted structs works; only the c.JSON path is affected.
type definitionRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
FieldConfig FieldConfig `json:"field_config"`
DisplayOrder int `json:"display_order"`
Options []CustomFieldOption `json:"options"`
ProjectIDs []int64 `json:"project_ids"`
}
// valueItem is one entry of the bulk write body. R4: the bulk POST
// /tasks/:task/custom-fields body is a BARE JSON array of these — not a
// {"values": [...]} wrapper — so it is decoded directly with encoding/json.
type valueItem struct {
CustomFieldDefinitionID int64 `json:"custom_field_definition_id"`
Value interface{} `json:"value"`
}
// singleValueRequest is the body of the per-field POST/PUT: {"value": ...}.
type singleValueRequest struct {
Value interface{} `json:"value"`
}
// valueToMap builds the {value, field} entry for the read response. fieldMap is
// the definition's metadata (built by S2's definitionToMap, reused). value is
// the coerced native value, or nil if absent/invalid.
func valueToMap(value interface{}, fieldMap map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"value": value,
"field": fieldMap,
}
}
// fieldConfigMap builds the field_config map with concrete float64 values
// (dereferenced pointers) so c.JSON never has to marshal a yaegi-wrapped *float64.
func fieldConfigMap(fc FieldConfig) map[string]interface{} {
m := map[string]interface{}{
"required": fc.Required,
"default": fc.Default,
"is_api_only": fc.IsAPIOnly,
}
if fc.Min != nil {
m["min"] = *fc.Min
}
if fc.Max != nil {
m["max"] = *fc.Max
}
return m
}
// definitionFieldsMap is the definition fields only (for list items, no relations).
func definitionFieldsMap(d *CustomFieldDefinition) map[string]interface{} {
return map[string]interface{}{
"id": d.ID,
"name": d.Name,
"type": d.Type,
"description": d.Description,
"field_config": fieldConfigMap(d.FieldConfig),
"display_order": d.DisplayOrder,
}
}
// definitionToMap is the full single-resource response: definition fields +
// resolved options + project_ids ([] for global). Used by create/read/update.
func definitionToMap(d *CustomFieldDefinition, opts []CustomFieldOption, pids []int64) map[string]interface{} {
m := definitionFieldsMap(d)
optMaps := make([]map[string]interface{}, 0, len(opts))
for _, o := range opts {
optMaps = append(optMaps, map[string]interface{}{
"id": o.ID,
"custom_field_definition_id": o.CustomFieldDefinitionID,
"value": o.Value,
"label": o.Label,
"display_order": o.DisplayOrder,
})
}
m["options"] = optMaps
m["project_ids"] = pids
return m
}
// validateProjectIDList (R4) rejects a client-supplied project_id == 0 — the
// reserved internal sentinel. Clients express "all projects" via [] (omitted),
// never via [0]. This makes ErrCustomFieldGlobalConflict reachable and separates
// the mutual-exclusivity guard from validateAssignment's existence check.
func validateProjectIDList(ids []int64) error {
for _, pid := range ids {
if pid == 0 {
return ErrCustomFieldGlobalConflict{}
}
}
return nil
}
// toHTTPError translates plugin-local errors to echo HTTP errors. web.HTTPError
// is unavailable to yaegi, so this uses echo.NewHTTPError(code, message).
func toHTTPError(err error) error {
msg := err.Error()
// yaegi wraps interpreted errors as interp._error, so switch err.(type) never
// matches — discriminate by message prefix instead. Messages are stable
// constants from our error types; wrapped DB errors start with "custom-fields:"
// and fall to the 500 default. Upstream conversion: revert to switch err.(type)
// (type assertions work in native Go). Second yaegi deviation: a switch-true
// case list ("case a, b, c:") evaluates only its first expression, so each
// prefix gets its own case clause.
switch {
case strings.HasPrefix(msg, "custom field definition ") && strings.Contains(msg, " not found"):
return echo.NewHTTPError(http.StatusNotFound, msg)
case strings.HasPrefix(msg, "custom field name must not be empty"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "invalid custom field type:"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "options are only allowed for select/multiselect"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "duplicate option value:"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "constraint "):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "invalid constraint:"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "project ") && strings.Contains(msg, " does not exist"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "a field is either global"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
// S3 value errors. R1: one unique prefix per case — yaegi evaluates only the
// first expression of a multi-expression case clause, so HasPrefix+Contains
// pairs would collide. Prefixes here are the Task 2 error messages; all are
// distinct from S2's "custom field definition ..." / "custom field name ...".
case strings.HasPrefix(msg, "invalid value for"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "value for a required field must not be empty"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "option value"):
return echo.NewHTTPError(http.StatusBadRequest, msg)
case strings.HasPrefix(msg, "custom field value already exists"):
return echo.NewHTTPError(http.StatusConflict, msg)
case strings.HasPrefix(msg, "custom field value not found"):
return echo.NewHTTPError(http.StatusNotFound, msg)
case strings.HasPrefix(msg, "task ") && strings.Contains(msg, "not found"):
return echo.NewHTTPError(http.StatusNotFound, msg)
default:
return echo.NewHTTPError(http.StatusInternalServerError, msg)
}
}
func createHandler(c *echo.Context) error {
u, err := user.GetCurrentUser(c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
}
var req definitionRequest
if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
}
if err := validateProjectIDList(req.ProjectIDs); err != nil { // R4: reject sentinel
return toHTTPError(err)
}
d := &CustomFieldDefinition{
Name: req.Name, Type: req.Type, Description: req.Description,
FieldConfig: req.FieldConfig, DisplayOrder: req.DisplayOrder,
}
s := db.NewSession()
defer s.Close()
ok, err := d.CanCreate(s, u)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "not permitted to manage custom fields")
}
created, err := d.Create(s, u, req.Options, req.ProjectIDs)
if err != nil {
return toHTTPError(err)
}
if err := s.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
// R5: re-read for a canonical response (real option IDs, resolved project_ids).
rd := &CustomFieldDefinition{ID: created.ID}
def, opts, pids, err := rd.ReadOne(s)
if err != nil {
return toHTTPError(err)
}
return c.JSON(http.StatusCreated, definitionToMap(def, opts, pids))
}
func readOneHandler(c *echo.Context) error {
u, err := user.GetCurrentUser(c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
}
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
}
d := &CustomFieldDefinition{ID: id}
s := db.NewSession()
defer s.Close()
ok, err := d.CanRead(s, u)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "not permitted to manage custom fields")
}
def, opts, pids, err := d.ReadOne(s)
if err != nil {
return toHTTPError(err)
}
return c.JSON(http.StatusOK, definitionToMap(def, opts, pids))
}
func listHandler(c *echo.Context) error {
u, err := user.GetCurrentUser(c)
if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized")
}
d := &CustomFieldDefinition{}
s := db.NewSession()
defer s.Close()
ok, err := d.CanRead(s, u)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
if !ok {
return echo.NewHTTPError(http.StatusForbidden, "not permitted to manage custom fields")