diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..19d8ce6 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./start.sh" + cmd = "go build -o ./tmp/main cmd/cmd.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", ".dev"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml new file mode 100644 index 0000000..530e55e --- /dev/null +++ b/.github/workflows/nightly.yaml @@ -0,0 +1,73 @@ +name: Nightly Build + +on: + schedule: + - cron: '0 0 * * *' # Runs every day at midnight + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE: ${{ github.repository }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + + # get date for versioning + - name: Get date + id: date + run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_ENV + + - name: Build ui + run: | + cd ui + npm install + node_modules/.bin/vite build --outDir ../pkg/app/static/files + + - name: Build app + run: CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static" -X "main.version=nightly-${{ steps.date.outputs.date }}"' -o bin/app cmd/cmd.go + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE }} + tags: | + type=schedule,pattern=nightly + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml new file mode 100644 index 0000000..c590c75 --- /dev/null +++ b/.github/workflows/pr.yaml @@ -0,0 +1,51 @@ +name: PR + +on: + pull_request: + branches: + - main + +permissions: + contents: write + packages: write + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + registry-url: 'https://npm.pkg.github.com' + + - uses: nowsprinting/check-version-format-action@v3 + if: github.event_name != 'pull_request' + id: version + with: + prefix: 'v' + + - name: Build ui + run: | + cd ui + npm install + node_modules/.bin/vite build --outDir ../infrastructure/static/files + env: + NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Build app + run: CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static" -X "main.version=${{ steps.version.outputs.full }}"' -o bin/app cmd/cmd.go diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..6af271c --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,76 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE: ${{ github.repository }} + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: ui/package-lock.json + registry-url: 'https://npm.pkg.github.com' + + - uses: nowsprinting/check-version-format-action@v3 + if: github.event_name != 'pull_request' + id: version + with: + prefix: 'v' + + - name: Build ui + run: | + cd ui + npm install + node_modules/.bin/vite build --outDir ../infrastructure/static/files + + - name: Build app + run: CGO_ENABLED=1 GOOS=linux go build -a -ldflags '-linkmode external -extldflags "-static" -X "main.version=${{ steps.version.outputs.full }}"' -o bin/app cmd/cmd.go + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + if: github.event_name != 'pull_request' + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..8336052 --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +tmp/ + +database.db +database.sqlite +.DS_Store + +.claude \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b7a3578 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ui"] + path = ui + url = git@github.com:Labbs/nexo-ui.git diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..c914126 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.25 as builder + +WORKDIR /app +COPY bin/app /app/bin/app + +FROM alpine:latest as release +RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/* +COPY --from=builder /app/bin/app . +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f421df --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +.PHONY: help licenses licenses-check licenses-install + +GO_LICENSES := $(shell which go-licenses 2>/dev/null) + +help: + @echo "Available targets:" + @echo " licenses - Check licenses of all dependencies (installs go-licenses if needed)" + @echo " licenses-check - Check licenses without installing" + @echo " licenses-install - Install go-licenses tool" + +licenses-install: + @echo "Installing go-licenses..." + go install github.com/google/go-licenses@latest + +licenses: +ifndef GO_LICENSES + @echo "go-licenses not found, installing..." + @$(MAKE) licenses-install +endif + @echo "Checking licenses..." + go-licenses csv ./... 2>/dev/null | column -t -s, + +licenses-check: +ifdef GO_LICENSES + go-licenses csv ./... 2>/dev/null | column -t -s, +else + @echo "Error: go-licenses not installed. Run 'make licenses-install' first." + @exit 1 +endif diff --git a/README.md b/README.md index a9c07b7..93aed74 100644 --- a/README.md +++ b/README.md @@ -1 +1,3 @@ -# Nexo \ No newline at end of file +# nexo + +Nexo is a self-hosted alternative to Notion. Organize documents, build flexible databases with multiple views (table, board, calendar, gallery), and automate workflows — all under your control. diff --git a/application/action/action.go b/application/action/action.go new file mode 100644 index 0000000..4b37b91 --- /dev/null +++ b/application/action/action.go @@ -0,0 +1,360 @@ +package action + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/action/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type ActionApp struct { + Config config.Config + Logger zerolog.Logger + ActionPers domain.ActionPers + ActionRunPers domain.ActionRunPers +} + +func NewActionApp(config config.Config, logger zerolog.Logger, actionPers domain.ActionPers, actionRunPers domain.ActionRunPers) *ActionApp { + return &ActionApp{ + Config: config, + Logger: logger, + ActionPers: actionPers, + ActionRunPers: actionRunPers, + } +} + +func (app *ActionApp) CreateAction(input dto.CreateActionInput) (*dto.CreateActionOutput, error) { + // Build steps JSONB + stepsJSON, err := json.Marshal(input.Steps) + if err != nil { + return nil, fmt.Errorf("failed to marshal steps: %w", err) + } + var steps domain.JSONB + json.Unmarshal(stepsJSON, &steps) + + // Build trigger config JSONB + var triggerConfig domain.JSONB + if input.TriggerConfig != nil { + triggerConfig = domain.JSONB(input.TriggerConfig) + } + + action := &domain.Action{ + Id: uuid.New().String(), + UserId: input.UserId, + SpaceId: input.SpaceId, + DatabaseId: input.DatabaseId, + Name: input.Name, + Description: input.Description, + TriggerType: domain.ActionTriggerType(input.TriggerType), + TriggerConfig: triggerConfig, + Steps: steps, + Active: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.ActionPers.Create(action); err != nil { + return nil, fmt.Errorf("failed to create action: %w", err) + } + + return &dto.CreateActionOutput{ + Id: action.Id, + Name: action.Name, + Description: action.Description, + TriggerType: input.TriggerType, + Active: action.Active, + CreatedAt: action.CreatedAt, + }, nil +} + +func (app *ActionApp) ListActions(input dto.ListActionsInput) (*dto.ListActionsOutput, error) { + actions, err := app.ActionPers.GetByUserId(input.UserId) + if err != nil { + return nil, fmt.Errorf("failed to list actions: %w", err) + } + + output := &dto.ListActionsOutput{ + Actions: make([]dto.ActionItem, len(actions)), + } + + for i, a := range actions { + item := dto.ActionItem{ + Id: a.Id, + Name: a.Name, + Description: a.Description, + SpaceId: a.SpaceId, + DatabaseId: a.DatabaseId, + TriggerType: string(a.TriggerType), + Active: a.Active, + LastRunAt: a.LastRunAt, + LastError: a.LastError, + RunCount: a.RunCount, + SuccessCount: a.SuccessCount, + FailureCount: a.FailureCount, + CreatedAt: a.CreatedAt, + } + + if a.Space != nil { + item.SpaceName = &a.Space.Name + } + + output.Actions[i] = item + } + + return output, nil +} + +func (app *ActionApp) GetAction(input dto.GetActionInput) (*dto.GetActionOutput, error) { + action, err := app.ActionPers.GetById(input.ActionId) + if err != nil { + return nil, fmt.Errorf("action not found: %w", err) + } + + if action.UserId != input.UserId { + return nil, fmt.Errorf("access denied") + } + + // Parse steps + var steps []dto.ActionStep + if action.Steps != nil { + stepsJSON, _ := json.Marshal(action.Steps) + json.Unmarshal(stepsJSON, &steps) + } + + output := &dto.GetActionOutput{ + Id: action.Id, + Name: action.Name, + Description: action.Description, + SpaceId: action.SpaceId, + DatabaseId: action.DatabaseId, + TriggerType: string(action.TriggerType), + TriggerConfig: map[string]interface{}(action.TriggerConfig), + Steps: steps, + Active: action.Active, + LastRunAt: action.LastRunAt, + LastError: action.LastError, + RunCount: action.RunCount, + SuccessCount: action.SuccessCount, + FailureCount: action.FailureCount, + CreatedAt: action.CreatedAt, + UpdatedAt: action.UpdatedAt, + } + + if action.Space != nil { + output.SpaceName = &action.Space.Name + } + + return output, nil +} + +func (app *ActionApp) UpdateAction(input dto.UpdateActionInput) error { + action, err := app.ActionPers.GetById(input.ActionId) + if err != nil { + return fmt.Errorf("action not found: %w", err) + } + + if action.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if input.Name != nil { + action.Name = *input.Name + } + + if input.Description != nil { + action.Description = *input.Description + } + + if input.TriggerType != nil { + action.TriggerType = domain.ActionTriggerType(*input.TriggerType) + } + + if input.TriggerConfig != nil { + action.TriggerConfig = domain.JSONB(input.TriggerConfig) + } + + if input.Steps != nil { + stepsJSON, err := json.Marshal(input.Steps) + if err != nil { + return fmt.Errorf("failed to marshal steps: %w", err) + } + var steps domain.JSONB + json.Unmarshal(stepsJSON, &steps) + action.Steps = steps + } + + if input.Active != nil { + action.Active = *input.Active + } + + action.UpdatedAt = time.Now() + + if err := app.ActionPers.Update(action); err != nil { + return fmt.Errorf("failed to update action: %w", err) + } + + return nil +} + +func (app *ActionApp) DeleteAction(input dto.DeleteActionInput) error { + action, err := app.ActionPers.GetById(input.ActionId) + if err != nil { + return fmt.Errorf("action not found: %w", err) + } + + if action.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if err := app.ActionPers.Delete(input.ActionId); err != nil { + return fmt.Errorf("failed to delete action: %w", err) + } + + return nil +} + +func (app *ActionApp) GetRuns(input dto.GetRunsInput) (*dto.GetRunsOutput, error) { + // Verify ownership + action, err := app.ActionPers.GetById(input.ActionId) + if err != nil { + return nil, fmt.Errorf("action not found: %w", err) + } + + if action.UserId != input.UserId { + return nil, fmt.Errorf("access denied") + } + + limit := input.Limit + if limit <= 0 { + limit = 20 + } + + runs, err := app.ActionRunPers.GetByActionId(input.ActionId, limit) + if err != nil { + return nil, fmt.Errorf("failed to get runs: %w", err) + } + + output := &dto.GetRunsOutput{ + Runs: make([]dto.RunItem, len(runs)), + } + + for i, r := range runs { + output.Runs[i] = dto.RunItem{ + Id: r.Id, + Success: r.Success, + Error: r.Error, + Duration: r.Duration, + CreatedAt: r.CreatedAt, + } + } + + return output, nil +} + +// ExecuteActions triggers all matching actions for an event +func (app *ActionApp) ExecuteActions(input dto.ExecuteActionInput) { + logger := app.Logger.With().Str("component", "action.execute").Str("trigger", input.TriggerType).Logger() + + actions, err := app.ActionPers.GetActiveByTrigger(domain.ActionTriggerType(input.TriggerType), input.SpaceId, input.DatabaseId) + if err != nil { + logger.Error().Err(err).Msg("failed to get actions for trigger") + return + } + + for _, action := range actions { + go app.executeAction(action, input.TriggerData) + } +} + +func (app *ActionApp) executeAction(action domain.Action, triggerData map[string]interface{}) { + logger := app.Logger.With(). + Str("component", "action.execute"). + Str("action_id", action.Id). + Str("trigger", string(action.TriggerType)). + Logger() + + start := time.Now() + + // Update last run + _ = app.ActionPers.UpdateLastRun(action.Id) + + // Parse steps + var steps []dto.ActionStep + if action.Steps != nil { + stepsJSON, _ := json.Marshal(action.Steps) + json.Unmarshal(stepsJSON, &steps) + } + + // Execute each step + stepsResult := make([]map[string]interface{}, len(steps)) + var execError error + + for i, step := range steps { + result, err := app.executeStep(step, triggerData) + stepsResult[i] = map[string]interface{}{ + "step": i + 1, + "type": step.Type, + "success": err == nil, + "result": result, + } + if err != nil { + stepsResult[i]["error"] = err.Error() + execError = err + break // Stop on first error + } + } + + duration := int(time.Since(start).Milliseconds()) + + // Record the run + run := &domain.ActionRun{ + Id: uuid.New().String(), + ActionId: action.Id, + TriggerData: domain.JSONB(triggerData), + StepsResult: domain.JSONB{"steps": stepsResult}, + Success: execError == nil, + Duration: duration, + CreatedAt: time.Now(), + } + + if execError != nil { + run.Error = execError.Error() + _ = app.ActionPers.RecordFailure(action.Id, execError.Error()) + logger.Warn().Err(execError).Msg("action execution failed") + } else { + _ = app.ActionPers.IncrementSuccess(action.Id) + logger.Debug().Msg("action executed successfully") + } + + if err := app.ActionRunPers.Create(run); err != nil { + logger.Error().Err(err).Msg("failed to record action run") + } +} + +func (app *ActionApp) executeStep(step dto.ActionStep, triggerData map[string]interface{}) (interface{}, error) { + // This is a simplified implementation - in production, you'd have actual step executors + switch domain.ActionStepType(step.Type) { + case domain.StepSendWebhook: + // Would call webhook service here + return map[string]string{"status": "webhook_sent"}, nil + case domain.StepSendEmail: + // Would call email service here + return map[string]string{"status": "email_sent"}, nil + case domain.StepSendSlack: + // Would call Slack API here + return map[string]string{"status": "slack_sent"}, nil + case domain.StepUpdateProperty: + // Would update property in database + return map[string]string{"status": "property_updated"}, nil + case domain.StepAddComment: + // Would add comment + return map[string]string{"status": "comment_added"}, nil + default: + return nil, fmt.Errorf("unsupported step type: %s", step.Type) + } +} diff --git a/application/action/dto/action.go b/application/action/dto/action.go new file mode 100644 index 0000000..a9b602c --- /dev/null +++ b/application/action/dto/action.go @@ -0,0 +1,127 @@ +package dto + +import "time" + +// Step configuration +type ActionStep struct { + Type string `json:"type"` + Config map[string]interface{} `json:"config"` +} + +// Create action +type CreateActionInput struct { + UserId string + SpaceId *string + DatabaseId *string + Name string + Description string + TriggerType string + TriggerConfig map[string]interface{} + Steps []ActionStep +} + +type CreateActionOutput struct { + Id string + Name string + Description string + TriggerType string + Active bool + CreatedAt time.Time +} + +// List actions +type ListActionsInput struct { + UserId string +} + +type ActionItem struct { + Id string + Name string + Description string + SpaceId *string + SpaceName *string + DatabaseId *string + TriggerType string + Active bool + LastRunAt *time.Time + LastError string + RunCount int + SuccessCount int + FailureCount int + CreatedAt time.Time +} + +type ListActionsOutput struct { + Actions []ActionItem +} + +// Get action +type GetActionInput struct { + UserId string + ActionId string +} + +type GetActionOutput struct { + Id string + Name string + Description string + SpaceId *string + SpaceName *string + DatabaseId *string + TriggerType string + TriggerConfig map[string]interface{} + Steps []ActionStep + Active bool + LastRunAt *time.Time + LastError string + RunCount int + SuccessCount int + FailureCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +// Update action +type UpdateActionInput struct { + UserId string + ActionId string + Name *string + Description *string + TriggerType *string + TriggerConfig map[string]interface{} + Steps *[]ActionStep + Active *bool +} + +// Delete action +type DeleteActionInput struct { + UserId string + ActionId string +} + +// Get runs +type GetRunsInput struct { + UserId string + ActionId string + Limit int +} + +type RunItem struct { + Id string + Success bool + Error string + Duration int + CreatedAt time.Time +} + +type GetRunsOutput struct { + Runs []RunItem +} + +// Execute action (internal) +type ExecuteActionInput struct { + TriggerType string + SpaceId *string + DatabaseId *string + TriggerData map[string]interface{} +} diff --git a/application/apikey/admin.go b/application/apikey/admin.go new file mode 100644 index 0000000..b5c915c --- /dev/null +++ b/application/apikey/admin.go @@ -0,0 +1,28 @@ +package apikey + +import ( + "fmt" + + "github.com/labbs/nexo/domain" +) + +// GetAllApiKeys returns all API keys with pagination (admin only) +func (app *ApiKeyApp) GetAllApiKeys(limit, offset int) ([]domain.ApiKey, int64, error) { + logger := app.Logger.With().Str("component", "application.apikey.get_all_apikeys").Logger() + + apiKeys, total, err := app.ApiKeyPers.GetAll(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get all API keys") + return nil, 0, err + } + + return apiKeys, total, nil +} + +// AdminDeleteApiKey deletes an API key without ownership check (admin only) +func (app *ApiKeyApp) AdminDeleteApiKey(apiKeyId string) error { + if err := app.ApiKeyPers.Delete(apiKeyId); err != nil { + return fmt.Errorf("failed to delete API key: %w", err) + } + return nil +} diff --git a/application/apikey/apikey.go b/application/apikey/apikey.go new file mode 100644 index 0000000..3d5d798 --- /dev/null +++ b/application/apikey/apikey.go @@ -0,0 +1,210 @@ +package apikey + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/apikey/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type ApiKeyApp struct { + Config config.Config + Logger zerolog.Logger + ApiKeyPers domain.ApiKeyPers +} + +func NewApiKeyApp(config config.Config, logger zerolog.Logger, apiKeyPers domain.ApiKeyPers) *ApiKeyApp { + return &ApiKeyApp{ + Config: config, + Logger: logger, + ApiKeyPers: apiKeyPers, + } +} + +// generateApiKey generates a secure random API key +func generateApiKey() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return "zk_" + hex.EncodeToString(bytes), nil +} + +// hashApiKey creates a SHA-256 hash of the API key +func hashApiKey(key string) string { + hash := sha256.Sum256([]byte(key)) + return hex.EncodeToString(hash[:]) +} + +func (app *ApiKeyApp) CreateApiKey(input dto.CreateApiKeyInput) (*dto.CreateApiKeyOutput, error) { + // Generate the API key + plainKey, err := generateApiKey() + if err != nil { + return nil, fmt.Errorf("failed to generate API key: %w", err) + } + + // Hash the key for storage + keyHash := hashApiKey(plainKey) + + // Create the key prefix for identification (first 11 chars: "zk_" + 8 chars) + keyPrefix := plainKey[:11] + + // Build permissions JSON + permissions := domain.JSONB{ + "scopes": input.Scopes, + } + + apiKey := &domain.ApiKey{ + Id: uuid.New().String(), + UserId: input.UserId, + Name: input.Name, + KeyHash: keyHash, + KeyPrefix: keyPrefix, + Permissions: permissions, + ExpiresAt: input.ExpiresAt, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.ApiKeyPers.Create(apiKey); err != nil { + return nil, fmt.Errorf("failed to create API key: %w", err) + } + + return &dto.CreateApiKeyOutput{ + Id: apiKey.Id, + Name: apiKey.Name, + Key: plainKey, // Only returned once + KeyPrefix: keyPrefix, + Scopes: input.Scopes, + ExpiresAt: input.ExpiresAt, + CreatedAt: apiKey.CreatedAt, + }, nil +} + +func (app *ApiKeyApp) ListApiKeys(input dto.ListApiKeysInput) (*dto.ListApiKeysOutput, error) { + apiKeys, err := app.ApiKeyPers.GetByUserId(input.UserId) + if err != nil { + return nil, fmt.Errorf("failed to list API keys: %w", err) + } + + output := &dto.ListApiKeysOutput{ + ApiKeys: make([]dto.ApiKeyItem, len(apiKeys)), + } + + for i, k := range apiKeys { + var scopes []string + if k.Permissions != nil { + if s, ok := k.Permissions["scopes"].([]interface{}); ok { + for _, scope := range s { + if str, ok := scope.(string); ok { + scopes = append(scopes, str) + } + } + } + } + + output.ApiKeys[i] = dto.ApiKeyItem{ + Id: k.Id, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + Scopes: scopes, + LastUsedAt: k.LastUsedAt, + ExpiresAt: k.ExpiresAt, + CreatedAt: k.CreatedAt, + } + } + + return output, nil +} + +func (app *ApiKeyApp) DeleteApiKey(input dto.DeleteApiKeyInput) error { + apiKey, err := app.ApiKeyPers.GetById(input.ApiKeyId) + if err != nil { + return fmt.Errorf("API key not found: %w", err) + } + + // Verify ownership + if apiKey.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if err := app.ApiKeyPers.Delete(input.ApiKeyId); err != nil { + return fmt.Errorf("failed to delete API key: %w", err) + } + + return nil +} + +func (app *ApiKeyApp) UpdateApiKey(input dto.UpdateApiKeyInput) error { + apiKey, err := app.ApiKeyPers.GetById(input.ApiKeyId) + if err != nil { + return fmt.Errorf("API key not found: %w", err) + } + + // Verify ownership + if apiKey.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if input.Name != nil { + apiKey.Name = *input.Name + } + + if input.Scopes != nil { + apiKey.Permissions = domain.JSONB{ + "scopes": *input.Scopes, + } + } + + apiKey.UpdatedAt = time.Now() + + if err := app.ApiKeyPers.Update(apiKey); err != nil { + return fmt.Errorf("failed to update API key: %w", err) + } + + return nil +} + +func (app *ApiKeyApp) ValidateApiKey(input dto.ValidateApiKeyInput) (*dto.ValidateApiKeyOutput, error) { + keyHash := hashApiKey(input.Key) + + apiKey, err := app.ApiKeyPers.GetByKeyHash(keyHash) + if err != nil { + return &dto.ValidateApiKeyOutput{Valid: false}, nil + } + + // Check if expired + if apiKey.ExpiresAt != nil && apiKey.ExpiresAt.Before(time.Now()) { + return &dto.ValidateApiKeyOutput{Valid: false, Expired: true}, nil + } + + // Update last used timestamp asynchronously + go func() { + _ = app.ApiKeyPers.UpdateLastUsed(apiKey.Id) + }() + + // Extract scopes + var scopes []string + if apiKey.Permissions != nil { + if s, ok := apiKey.Permissions["scopes"].([]interface{}); ok { + for _, scope := range s { + if str, ok := scope.(string); ok { + scopes = append(scopes, str) + } + } + } + } + + return &dto.ValidateApiKeyOutput{ + Valid: true, + UserId: apiKey.UserId, + Scopes: scopes, + }, nil +} diff --git a/application/apikey/dto/apikey.go b/application/apikey/dto/apikey.go new file mode 100644 index 0000000..a8a540a --- /dev/null +++ b/application/apikey/dto/apikey.go @@ -0,0 +1,66 @@ +package dto + +import "time" + +// Create API key +type CreateApiKeyInput struct { + UserId string + Name string + Scopes []string + ExpiresAt *time.Time +} + +type CreateApiKeyOutput struct { + Id string + Name string + Key string // Plain text key - only returned once at creation + KeyPrefix string + Scopes []string + ExpiresAt *time.Time + CreatedAt time.Time +} + +// List API keys +type ListApiKeysInput struct { + UserId string +} + +type ApiKeyItem struct { + Id string + Name string + KeyPrefix string + Scopes []string + LastUsedAt *time.Time + ExpiresAt *time.Time + CreatedAt time.Time +} + +type ListApiKeysOutput struct { + ApiKeys []ApiKeyItem +} + +// Delete API key +type DeleteApiKeyInput struct { + UserId string + ApiKeyId string +} + +// Update API key +type UpdateApiKeyInput struct { + UserId string + ApiKeyId string + Name *string + Scopes *[]string +} + +// Validate API key (for authentication) +type ValidateApiKeyInput struct { + Key string +} + +type ValidateApiKeyOutput struct { + Valid bool + UserId string + Scopes []string + Expired bool +} diff --git a/application/auth/auth.go b/application/auth/auth.go new file mode 100644 index 0000000..8286af1 --- /dev/null +++ b/application/auth/auth.go @@ -0,0 +1,27 @@ +package auth + +import ( + "github.com/labbs/nexo/application/ports" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type AuthApp struct { + Config config.Config + Logger zerolog.Logger + UserApp ports.UserPort + SessionApp ports.SessionPort + SpaceApp ports.SpacePort + DocumentApp ports.DocumentPort +} + +func NewAuthApp(config config.Config, logger zerolog.Logger, userApp ports.UserPort, sessionApp ports.SessionPort, spaceApp ports.SpacePort, documentApp ports.DocumentPort) *AuthApp { + return &AuthApp{ + Config: config, + Logger: logger, + UserApp: userApp, + SessionApp: sessionApp, + SpaceApp: spaceApp, + DocumentApp: documentApp, + } +} diff --git a/application/auth/authenticate.go b/application/auth/authenticate.go new file mode 100644 index 0000000..9dfb5ce --- /dev/null +++ b/application/auth/authenticate.go @@ -0,0 +1,53 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/labbs/nexo/application/auth/dto" + s "github.com/labbs/nexo/application/session/dto" + u "github.com/labbs/nexo/application/user/dto" + "github.com/labbs/nexo/infrastructure/helpers/tokenutil" + "golang.org/x/crypto/bcrypt" +) + +func (c *AuthApp) Authenticate(input dto.AuthenticateInput) (*dto.AuthenticateOutput, error) { + logger := c.Logger.With().Str("component", "application.auth.authenticate").Logger() + + resp, err := c.UserApp.GetByEmail(u.GetByEmailInput{Email: input.Email}) + if err != nil { + return nil, fmt.Errorf("failed to get user by email: %w", err) + } + + if !resp.User.Active { + logger.Warn().Str("email", input.Email).Msg("attempt to authenticate inactive user") + return nil, fmt.Errorf("user is not active") + } + + err = bcrypt.CompareHashAndPassword([]byte(resp.User.Password), []byte(input.Password)) + if err != nil { + logger.Warn().Str("email", input.Email).Msg("invalid password attempt") + return nil, fmt.Errorf("invalid credentials") + } + + sessionResult, err := c.SessionApp.Create(s.CreateSessionInput{ + UserId: resp.User.Id, + UserAgent: input.Context.Get("User-Agent"), + IpAddress: input.Context.IP(), + ExpiresAt: time.Now().Add(time.Minute * time.Duration(c.Config.Session.ExpirationMinutes)), + }) + if err != nil { + logger.Error().Err(err).Str("user_id", resp.User.Id).Msg("failed to create session") + return nil, fmt.Errorf("failed to create session: %w", err) + } + + accessToken, err := tokenutil.CreateAccessToken(resp.User.Id, sessionResult.SessionId, c.Config) + if err != nil { + logger.Error().Err(err).Str("user_id", resp.User.Id).Str("session_id", sessionResult.SessionId).Msg("failed to create access token") + return nil, fmt.Errorf("failed to create access token: %w", err) + } + + return &dto.AuthenticateOutput{ + Token: accessToken, + }, nil +} diff --git a/application/auth/dto/authenticate.go b/application/auth/dto/authenticate.go new file mode 100644 index 0000000..d5bcbe1 --- /dev/null +++ b/application/auth/dto/authenticate.go @@ -0,0 +1,13 @@ +package dto + +import "github.com/gofiber/fiber/v2" + +type AuthenticateInput struct { + Email string + Password string + Context *fiber.Ctx +} + +type AuthenticateOutput struct { + Token string +} diff --git a/application/auth/dto/logout.go b/application/auth/dto/logout.go new file mode 100644 index 0000000..296884b --- /dev/null +++ b/application/auth/dto/logout.go @@ -0,0 +1,5 @@ +package dto + +type LogoutInput struct { + SessionId string +} diff --git a/application/auth/dto/register.go b/application/auth/dto/register.go new file mode 100644 index 0000000..5ea051a --- /dev/null +++ b/application/auth/dto/register.go @@ -0,0 +1,7 @@ +package dto + +type RegisterInput struct { + Username string + Email string + Password string +} diff --git a/application/auth/logout.go b/application/auth/logout.go new file mode 100644 index 0000000..85e94e1 --- /dev/null +++ b/application/auth/logout.go @@ -0,0 +1,20 @@ +package auth + +import ( + "fmt" + + "github.com/labbs/nexo/application/auth/dto" + s "github.com/labbs/nexo/application/session/dto" +) + +func (c *AuthApp) Logout(input dto.LogoutInput) error { + logger := c.Logger.With().Str("component", "application.auth.logout").Logger() + + err := c.SessionApp.InvalidateSession(s.InvalidateSessionInput{SessionId: input.SessionId}) + if err != nil { + logger.Error().Err(err).Str("session_id", input.SessionId).Msg("failed to invalidate session") + return fmt.Errorf("failed to invalidate session: %w", err) + } + + return nil +} diff --git a/application/auth/register.go b/application/auth/register.go new file mode 100644 index 0000000..7c3032a --- /dev/null +++ b/application/auth/register.go @@ -0,0 +1,83 @@ +package auth + +import ( + "fmt" + + "github.com/labbs/nexo/application/auth/dto" + d "github.com/labbs/nexo/application/document/dto" + s "github.com/labbs/nexo/application/space/dto" + u "github.com/labbs/nexo/application/user/dto" + "github.com/labbs/nexo/domain" + "golang.org/x/crypto/bcrypt" +) + +func (c *AuthApp) Register(input dto.RegisterInput) error { + logger := c.Logger.With().Str("component", "application.auth.register").Logger() + + // check if the email is already in use + _, err := c.UserApp.GetByEmail(u.GetByEmailInput{Email: input.Email}) + if err == nil { + return fmt.Errorf("email is already in use") + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost) + if err != nil { + logger.Error().Err(err).Str("email", input.Email).Msg("failed to hash password") + return fmt.Errorf("failed to hash password: %w", err) + } + + user := domain.User{ + Username: input.Username, + Email: input.Email, + Password: string(hashedPassword), + Active: true, + } + + createdUser, err := c.UserApp.Create(u.CreateUserInput{User: user}) + if err != nil { + logger.Error().Err(err).Str("email", input.Email).Msg("failed to create user") + return fmt.Errorf("failed to create user: %w", err) + } + + // Create a private space for the user + fmt.Println("Creating private space for user", createdUser.User.Id) + space, err := c.SpaceApp.CreatePrivateSpaceForUser(s.CreatePrivateSpaceForUserInput{UserId: createdUser.User.Id}) + if err != nil { + logger.Error().Err(err).Str("user_id", createdUser.User.Id).Msg("failed to create private space for user") + return fmt.Errorf("failed to create private space for user: %w", err) + } + + // Create a welcome document in the user's private space + welcomeContent := []d.Block{ + { + ID: "welcome-1", + Type: d.BlockTypeParagraph, + Props: map[string]any{ + "textColor": "default", + "backgroundColor": "default", + "textAlignment": "left", + }, + Content: []d.InlineContent{ + { + Type: "text", + Text: "This is your private space. Start adding your notes and documents here!", + Styles: map[string]bool{}, + }, + }, + Children: []d.Block{}, + }, + } + + _, err = c.DocumentApp.CreateDocument(d.CreateDocumentInput{ + Name: "Welcome to Your Private Space", + UserId: createdUser.User.Id, + SpaceId: space.Space.Id, + Content: welcomeContent, + }) + if err != nil { + logger.Error().Err(err).Str("space_id", space.Space.Id).Str("user_id", createdUser.User.Id).Msg("failed to create welcome document") + return fmt.Errorf("failed to create welcome document: %w", err) + } + + return nil +} diff --git a/application/database/database.go b/application/database/database.go new file mode 100644 index 0000000..65ca00e --- /dev/null +++ b/application/database/database.go @@ -0,0 +1,934 @@ +package database + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/database/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type DatabaseApp struct { + Config config.Config + Logger zerolog.Logger + DatabasePers domain.DatabasePers + DatabaseRowPers domain.DatabaseRowPers + SpacePers domain.SpacePers + PermissionPers domain.PermissionPers +} + +func NewDatabaseApp(config config.Config, logger zerolog.Logger, databasePers domain.DatabasePers, databaseRowPers domain.DatabaseRowPers, spacePers domain.SpacePers, permissionPers domain.PermissionPers) *DatabaseApp { + return &DatabaseApp{ + Config: config, + Logger: logger, + DatabasePers: databasePers, + DatabaseRowPers: databaseRowPers, + SpacePers: spacePers, + PermissionPers: permissionPers, + } +} + +func (app *DatabaseApp) CreateDatabase(input dto.CreateDatabaseInput) (*dto.CreateDatabaseOutput, error) { + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + // Determine database type (default to spreadsheet) + dbType := domain.DatabaseTypeSpreadsheet + if input.Type == string(domain.DatabaseTypeDocument) { + dbType = domain.DatabaseTypeDocument + } + + // Use default schema if none provided + schemaToUse := input.Schema + if len(schemaToUse) == 0 { + if dbType == domain.DatabaseTypeDocument { + // Document databases only need a title field by default + schemaToUse = []dto.PropertySchema{ + {Id: "title", Name: "Title", Type: "title"}, + } + } else { + // Spreadsheet databases get name and date + schemaToUse = []dto.PropertySchema{ + {Id: "name", Name: "Name", Type: "title"}, + {Id: "date", Name: "Date", Type: "date"}, + } + } + } + + // Build schema JSONBArray + schemaJSON, err := json.Marshal(schemaToUse) + if err != nil { + return nil, fmt.Errorf("failed to marshal schema: %w", err) + } + var schema domain.JSONBArray + json.Unmarshal(schemaJSON, &schema) + + // Create default view (always table) + defaultViewType := domain.ViewTypeTable + defaultView := dto.ViewConfig{ + Id: uuid.New().String(), + Name: "Default", + Type: string(defaultViewType), + } + viewsJSON, _ := json.Marshal([]dto.ViewConfig{defaultView}) + var views domain.JSONBArray + json.Unmarshal(viewsJSON, &views) + + database := &domain.Database{ + Id: uuid.New().String(), + SpaceId: input.SpaceId, + DocumentId: input.DocumentId, + Name: input.Name, + Description: input.Description, + Icon: input.Icon, + Schema: schema, + Views: views, + DefaultView: string(defaultViewType), + Type: dbType, + CreatedBy: input.UserId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.DatabasePers.Create(database); err != nil { + return nil, fmt.Errorf("failed to create database: %w", err) + } + + // Auto-create editor permission for the creator + // This ensures they retain access even if their space role is downgraded + if err := app.PermissionPers.UpsertUser(domain.PermissionTypeDatabase, database.Id, input.UserId, domain.PermissionRoleEditor); err != nil { + // Log but don't fail - the database is already created + app.Logger.Warn().Err(err).Str("database_id", database.Id).Str("user_id", input.UserId).Msg("failed to create creator permission") + } + + // Create sample rows for new spreadsheet databases only (not for document databases) + if dbType == domain.DatabaseTypeSpreadsheet { + // Find the first "title" type column to use for sample data names + now := time.Now() + var titleColumnId string + for _, prop := range schemaToUse { + if prop.Type == "title" { + titleColumnId = prop.Id + break + } + } + if titleColumnId == "" && len(schemaToUse) > 0 { + titleColumnId = schemaToUse[0].Id // fallback to first column + } + + if titleColumnId != "" { + sampleNames := []string{"Data 1", "Data 2", "Data 3"} + for _, name := range sampleNames { + row := &domain.DatabaseRow{ + Id: uuid.New().String(), + DatabaseId: database.Id, + Properties: domain.JSONB{ + titleColumnId: name, + }, + CreatedBy: input.UserId, + CreatedAt: now, + UpdatedAt: now, + } + // Ignore errors for sample data - not critical + app.DatabaseRowPers.Create(row) + } + } + } + + return &dto.CreateDatabaseOutput{ + Id: database.Id, + Name: database.Name, + Description: database.Description, + Icon: database.Icon, + Schema: schemaToUse, + DefaultView: database.DefaultView, + Type: string(database.Type), + CreatedAt: database.CreatedAt, + }, nil +} + +func (app *DatabaseApp) ListDatabases(input dto.ListDatabasesInput) (*dto.ListDatabasesOutput, error) { + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + databases, err := app.DatabasePers.GetBySpaceId(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("failed to list databases: %w", err) + } + + output := &dto.ListDatabasesOutput{ + Databases: make([]dto.DatabaseItem, len(databases)), + } + + for i, db := range databases { + rowCount, _ := app.DatabaseRowPers.GetRowCount(db.Id) + output.Databases[i] = dto.DatabaseItem{ + Id: db.Id, + DocumentId: db.DocumentId, + Name: db.Name, + Description: db.Description, + Icon: db.Icon, + Type: string(db.Type), + RowCount: rowCount, + CreatedBy: db.User.Username, + CreatedAt: db.CreatedAt, + UpdatedAt: db.UpdatedAt, + } + } + + return output, nil +} + +func (app *DatabaseApp) GetDatabase(input dto.GetDatabaseInput) (*dto.GetDatabaseOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + // Parse schema + var schema []dto.PropertySchema + if database.Schema != nil { + schemaJSON, _ := json.Marshal(database.Schema) + json.Unmarshal(schemaJSON, &schema) + } + + // Parse views + var views []dto.ViewConfig + if database.Views != nil { + viewsJSON, _ := json.Marshal(database.Views) + json.Unmarshal(viewsJSON, &views) + } + + return &dto.GetDatabaseOutput{ + Id: database.Id, + SpaceId: database.SpaceId, + DocumentId: database.DocumentId, + Name: database.Name, + Description: database.Description, + Icon: database.Icon, + Schema: schema, + Views: views, + DefaultView: database.DefaultView, + Type: string(database.Type), + CreatedBy: database.User.Username, + CreatedAt: database.CreatedAt, + UpdatedAt: database.UpdatedAt, + }, nil +} + +func (app *DatabaseApp) UpdateDatabase(input dto.UpdateDatabaseInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if input.Name != nil { + database.Name = *input.Name + } + + if input.Description != nil { + database.Description = *input.Description + } + + if input.Icon != nil { + database.Icon = *input.Icon + } + + if input.Schema != nil { + schemaJSON, err := json.Marshal(input.Schema) + if err != nil { + return fmt.Errorf("failed to marshal schema: %w", err) + } + var schema domain.JSONBArray + json.Unmarshal(schemaJSON, &schema) + database.Schema = schema + } + + if input.DefaultView != nil { + database.DefaultView = *input.DefaultView + } + + database.UpdatedAt = time.Now() + + if err := app.DatabasePers.Update(database); err != nil { + return fmt.Errorf("failed to update database: %w", err) + } + + return nil +} + +func (app *DatabaseApp) DeleteDatabase(input dto.DeleteDatabaseInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if err := app.DatabasePers.Delete(input.DatabaseId); err != nil { + return fmt.Errorf("failed to delete database: %w", err) + } + + return nil +} + +// View operations + +func (app *DatabaseApp) CreateView(input dto.CreateViewInput) (*dto.CreateViewOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + // Parse existing views + var views []dto.ViewConfig + if database.Views != nil { + viewsJSON, _ := json.Marshal(database.Views) + json.Unmarshal(viewsJSON, &views) + } + + // Create new view + newView := dto.ViewConfig{ + Id: uuid.New().String(), + Name: input.Name, + Type: input.Type, + Filter: input.Filter, + Sort: input.Sort, + Columns: input.Columns, + } + + // Add to views array + views = append(views, newView) + + // Save back to database + viewsJSON, _ := json.Marshal(views) + var viewsArray domain.JSONBArray + json.Unmarshal(viewsJSON, &viewsArray) + database.Views = viewsArray + database.UpdatedAt = time.Now() + + if err := app.DatabasePers.Update(database); err != nil { + return nil, fmt.Errorf("failed to create view: %w", err) + } + + return &dto.CreateViewOutput{ + Id: newView.Id, + Name: newView.Name, + Type: newView.Type, + Filter: newView.Filter, + Sort: newView.Sort, + Columns: newView.Columns, + }, nil +} + +func (app *DatabaseApp) UpdateView(input dto.UpdateViewInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + // Parse existing views + var views []dto.ViewConfig + if database.Views != nil { + viewsJSON, _ := json.Marshal(database.Views) + json.Unmarshal(viewsJSON, &views) + } + + // Find and update the view + found := false + for i, view := range views { + if view.Id == input.ViewId { + if input.Name != nil { + views[i].Name = *input.Name + } + if input.Type != nil { + views[i].Type = *input.Type + } + // Filter: nil means no change, empty map {} means clear, otherwise update + if input.Filter != nil { + if len(input.Filter) == 0 { + // Empty object {} means clear the filter + views[i].Filter = nil + } else { + views[i].Filter = input.Filter + } + } + // Sort: nil means no change, empty slice [] means clear, otherwise update + if input.Sort != nil { + if len(input.Sort) == 0 { + // Empty array [] means clear the sort + views[i].Sort = nil + } else { + views[i].Sort = input.Sort + } + } + if input.Columns != nil { + views[i].Columns = input.Columns + } + // HiddenColumns: always update when provided (even empty array means "show all") + if input.HiddenColumns != nil { + views[i].HiddenColumns = input.HiddenColumns + } + // GroupBy: update when provided (used for board view grouping) + if input.GroupBy != nil { + views[i].GroupBy = *input.GroupBy + } + found = true + break + } + } + + if !found { + return fmt.Errorf("view not found") + } + + // Save back to database + viewsJSON, _ := json.Marshal(views) + var viewsArray domain.JSONBArray + json.Unmarshal(viewsJSON, &viewsArray) + database.Views = viewsArray + database.UpdatedAt = time.Now() + + if err := app.DatabasePers.Update(database); err != nil { + return fmt.Errorf("failed to update view: %w", err) + } + + return nil +} + +func (app *DatabaseApp) DeleteView(input dto.DeleteViewInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + // Parse existing views + var views []dto.ViewConfig + if database.Views != nil { + viewsJSON, _ := json.Marshal(database.Views) + json.Unmarshal(viewsJSON, &views) + } + + // Cannot delete the last view + if len(views) <= 1 { + return fmt.Errorf("cannot delete last view") + } + + // Find and remove the view + found := false + newViews := make([]dto.ViewConfig, 0, len(views)-1) + for _, view := range views { + if view.Id == input.ViewId { + found = true + continue + } + newViews = append(newViews, view) + } + + if !found { + return fmt.Errorf("view not found") + } + + // Save back to database + viewsJSON, _ := json.Marshal(newViews) + var viewsArray domain.JSONBArray + json.Unmarshal(viewsJSON, &viewsArray) + database.Views = viewsArray + database.UpdatedAt = time.Now() + + if err := app.DatabasePers.Update(database); err != nil { + return fmt.Errorf("failed to delete view: %w", err) + } + + return nil +} + +// Row operations + +func (app *DatabaseApp) CreateRow(input dto.CreateRowInput) (*dto.CreateRowOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + row := &domain.DatabaseRow{ + Id: uuid.New().String(), + DatabaseId: input.DatabaseId, + Properties: domain.JSONB(input.Properties), + Content: domain.JSONB(input.Content), + ShowInSidebar: input.ShowInSidebar, + CreatedBy: input.UserId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.DatabaseRowPers.Create(row); err != nil { + return nil, fmt.Errorf("failed to create row: %w", err) + } + + return &dto.CreateRowOutput{ + Id: row.Id, + Properties: input.Properties, + CreatedAt: row.CreatedAt, + }, nil +} + +func (app *DatabaseApp) ListRows(input dto.ListRowsInput) (*dto.ListRowsOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + limit := input.Limit + if limit <= 0 { + limit = 50 + } + + // Build query options from view if provided + var queryOptions domain.RowQueryOptions + queryOptions.Limit = limit + queryOptions.Offset = input.Offset + + if input.ViewId != "" { + // Parse views to find the requested view + var views []dto.ViewConfig + if database.Views != nil { + viewsJSON, _ := json.Marshal(database.Views) + json.Unmarshal(viewsJSON, &views) + } + + for _, view := range views { + if view.Id == input.ViewId { + // Convert view filter to domain filter + if view.Filter != nil { + queryOptions.Filter = convertFilterConfigToDomain(view.Filter) + } + // Convert view sort to domain sort + if len(view.Sort) > 0 { + queryOptions.Sort = make([]domain.SortRule, len(view.Sort)) + for i, s := range view.Sort { + queryOptions.Sort[i] = domain.SortRule{ + PropertyId: s.PropertyId, + Direction: s.Direction, + } + } + } + break + } + } + } + + var rows []domain.DatabaseRow + var totalCount int64 + + if queryOptions.Filter != nil || len(queryOptions.Sort) > 0 { + // Use filtered query + rows, err = app.DatabaseRowPers.GetByDatabaseIdWithOptions(input.DatabaseId, queryOptions) + if err != nil { + return nil, fmt.Errorf("failed to list rows: %w", err) + } + totalCount, err = app.DatabaseRowPers.GetRowCountWithFilter(input.DatabaseId, queryOptions.Filter) + if err != nil { + return nil, fmt.Errorf("failed to get row count: %w", err) + } + } else { + // Use simple query + rows, err = app.DatabaseRowPers.GetByDatabaseId(input.DatabaseId, limit, input.Offset) + if err != nil { + return nil, fmt.Errorf("failed to list rows: %w", err) + } + totalCount, err = app.DatabaseRowPers.GetRowCount(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("failed to get row count: %w", err) + } + } + + output := &dto.ListRowsOutput{ + Rows: make([]dto.RowItem, len(rows)), + TotalCount: totalCount, + } + + for i, row := range rows { + rowItem := dto.RowItem{ + Id: row.Id, + Properties: map[string]interface{}(row.Properties), + Content: map[string]interface{}(row.Content), + ShowInSidebar: row.ShowInSidebar, + CreatedBy: row.CreatedBy, + UpdatedBy: row.UpdatedBy, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.CreatedUser.Id != "" { + rowItem.CreatedByUser = &dto.UserInfo{ + Id: row.CreatedUser.Id, + Username: row.CreatedUser.Username, + AvatarUrl: row.CreatedUser.AvatarUrl, + } + } + if row.UpdatedUser.Id != "" { + rowItem.UpdatedByUser = &dto.UserInfo{ + Id: row.UpdatedUser.Id, + Username: row.UpdatedUser.Username, + AvatarUrl: row.UpdatedUser.AvatarUrl, + } + } + output.Rows[i] = rowItem + } + + return output, nil +} + +// convertFilterConfigToDomain converts the DTO filter config to domain filter config +func convertFilterConfigToDomain(filter map[string]interface{}) *domain.FilterConfig { + if filter == nil { + return nil + } + + result := &domain.FilterConfig{} + + // Handle "and" filters + if andRules, ok := filter["and"].([]interface{}); ok { + for _, r := range andRules { + if rule, ok := r.(map[string]interface{}); ok { + result.And = append(result.And, domain.FilterRule{ + Property: getString(rule, "property"), + Condition: getString(rule, "condition"), + Value: rule["value"], + }) + } + } + } + + // Handle "or" filters + if orRules, ok := filter["or"].([]interface{}); ok { + for _, r := range orRules { + if rule, ok := r.(map[string]interface{}); ok { + result.Or = append(result.Or, domain.FilterRule{ + Property: getString(rule, "property"), + Condition: getString(rule, "condition"), + Value: rule["value"], + }) + } + } + } + + return result +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key].(string); ok { + return v + } + return "" +} + +func (app *DatabaseApp) GetRow(input dto.GetRowInput) (*dto.GetRowOutput, error) { + row, err := app.DatabaseRowPers.GetById(input.RowId) + if err != nil { + return nil, fmt.Errorf("row not found: %w", err) + } + + if row.DatabaseId != input.DatabaseId { + return nil, fmt.Errorf("row not found in this database") + } + + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + output := &dto.GetRowOutput{ + Id: row.Id, + DatabaseId: row.DatabaseId, + Properties: map[string]interface{}(row.Properties), + Content: map[string]interface{}(row.Content), + ShowInSidebar: row.ShowInSidebar, + CreatedBy: row.CreatedBy, + UpdatedBy: row.UpdatedBy, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.CreatedUser.Id != "" { + output.CreatedByUser = &dto.UserInfo{ + Id: row.CreatedUser.Id, + Username: row.CreatedUser.Username, + AvatarUrl: row.CreatedUser.AvatarUrl, + } + } + if row.UpdatedUser.Id != "" { + output.UpdatedByUser = &dto.UserInfo{ + Id: row.UpdatedUser.Id, + Username: row.UpdatedUser.Username, + AvatarUrl: row.UpdatedUser.AvatarUrl, + } + } + return output, nil +} + +func (app *DatabaseApp) UpdateRow(input dto.UpdateRowInput) error { + row, err := app.DatabaseRowPers.GetById(input.RowId) + if err != nil { + return fmt.Errorf("row not found: %w", err) + } + + if row.DatabaseId != input.DatabaseId { + return fmt.Errorf("row not found in this database") + } + + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if input.Properties != nil { + row.Properties = domain.JSONB(input.Properties) + } + + if input.Content != nil { + row.Content = domain.JSONB(input.Content) + } + + if input.ShowInSidebar != nil { + row.ShowInSidebar = *input.ShowInSidebar + } + + row.UpdatedBy = input.UserId + row.UpdatedAt = time.Now() + + if err := app.DatabaseRowPers.Update(row); err != nil { + return fmt.Errorf("failed to update row: %w", err) + } + + return nil +} + +func (app *DatabaseApp) DeleteRow(input dto.DeleteRowInput) error { + row, err := app.DatabaseRowPers.GetById(input.RowId) + if err != nil { + return fmt.Errorf("row not found: %w", err) + } + + if row.DatabaseId != input.DatabaseId { + return fmt.Errorf("row not found in this database") + } + + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if err := app.DatabaseRowPers.Delete(input.RowId); err != nil { + return fmt.Errorf("failed to delete row: %w", err) + } + + return nil +} + +func (app *DatabaseApp) MoveDatabase(input dto.MoveDatabaseInput) (*dto.MoveDatabaseOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + database.DocumentId = input.DocumentId + database.UpdatedAt = time.Now() + + if err := app.DatabasePers.Update(database); err != nil { + return nil, fmt.Errorf("failed to move database: %w", err) + } + + return &dto.MoveDatabaseOutput{ + Id: database.Id, + DocumentId: database.DocumentId, + }, nil +} + +func (app *DatabaseApp) BulkDeleteRows(input dto.BulkDeleteRowsInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if err := app.DatabaseRowPers.BulkDelete(input.RowIds); err != nil { + return fmt.Errorf("failed to delete rows: %w", err) + } + + return nil +} + +func (app *DatabaseApp) Search(input dto.SearchDatabasesInput) (*dto.SearchDatabasesOutput, error) { + if len(input.Query) < 2 { + return nil, fmt.Errorf("query must be at least 2 characters") + } + + databases, err := app.DatabasePers.Search(input.Query, input.UserId, input.SpaceId, input.Limit) + if err != nil { + return nil, fmt.Errorf("failed to search databases: %w", err) + } + + output := &dto.SearchDatabasesOutput{ + Results: make([]dto.SearchDatabaseResultItem, len(databases)), + } + + for i, db := range databases { + output.Results[i] = dto.SearchDatabaseResultItem{ + Id: db.Id, + Name: db.Name, + Description: db.Description, + Icon: db.Icon, + Type: string(db.Type), + SpaceId: db.SpaceId, + SpaceName: db.Space.Name, + UpdatedAt: db.UpdatedAt, + } + } + + return output, nil +} diff --git a/application/database/dto/database.go b/application/database/dto/database.go new file mode 100644 index 0000000..850186c --- /dev/null +++ b/application/database/dto/database.go @@ -0,0 +1,278 @@ +package dto + +import "time" + +// UserInfo contains basic user information +type UserInfo struct { + Id string + Username string + AvatarUrl string +} + +// Property schema +type PropertySchema struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Options map[string]interface{} `json:"options,omitempty"` +} + +// View configuration +type ViewConfig struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Filter map[string]interface{} `json:"filter,omitempty"` + Sort []SortConfig `json:"sort,omitempty"` + Columns []string `json:"columns,omitempty"` + HiddenColumns []string `json:"hidden_columns,omitempty"` + GroupBy string `json:"group_by,omitempty"` +} + +type SortConfig struct { + PropertyId string `json:"property_id"` + Direction string `json:"direction"` // "asc" or "desc" +} + +// Create database +type CreateDatabaseInput struct { + UserId string + SpaceId string + DocumentId *string + Name string + Description string + Icon string + Schema []PropertySchema + Type string // "spreadsheet" or "document", defaults to "spreadsheet" +} + +type CreateDatabaseOutput struct { + Id string + Name string + Description string + Icon string + Schema []PropertySchema + DefaultView string + Type string + CreatedAt time.Time +} + +// List databases +type ListDatabasesInput struct { + UserId string + SpaceId string +} + +type DatabaseItem struct { + Id string + DocumentId *string + Name string + Description string + Icon string + Type string + RowCount int64 + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ListDatabasesOutput struct { + Databases []DatabaseItem +} + +// Get database +type GetDatabaseInput struct { + UserId string + DatabaseId string +} + +type GetDatabaseOutput struct { + Id string + SpaceId string + DocumentId *string + Name string + Description string + Icon string + Schema []PropertySchema + Views []ViewConfig + DefaultView string + Type string + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Update database +type UpdateDatabaseInput struct { + UserId string + DatabaseId string + Name *string + Description *string + Icon *string + Schema *[]PropertySchema + DefaultView *string +} + +// Delete database +type DeleteDatabaseInput struct { + UserId string + DatabaseId string +} + +// Create view +type CreateViewInput struct { + UserId string + DatabaseId string + Name string + Type string + Filter map[string]interface{} + Sort []SortConfig + Columns []string +} + +type CreateViewOutput struct { + Id string + Name string + Type string + Filter map[string]interface{} + Sort []SortConfig + Columns []string +} + +// Update view +type UpdateViewInput struct { + UserId string + DatabaseId string + ViewId string + Name *string + Type *string + Filter map[string]interface{} + Sort []SortConfig + Columns []string + HiddenColumns []string + GroupBy *string +} + +// Delete view +type DeleteViewInput struct { + UserId string + DatabaseId string + ViewId string +} + +// Row operations +type CreateRowInput struct { + UserId string + DatabaseId string + Properties map[string]interface{} + Content map[string]interface{} + ShowInSidebar bool +} + +type CreateRowOutput struct { + Id string + Properties map[string]interface{} + CreatedAt time.Time +} + +type ListRowsInput struct { + UserId string + DatabaseId string + ViewId string + Limit int + Offset int +} + +type RowItem struct { + Id string + Properties map[string]interface{} + Content map[string]interface{} + ShowInSidebar bool + CreatedBy string + CreatedByUser *UserInfo + UpdatedBy string + UpdatedByUser *UserInfo + CreatedAt time.Time + UpdatedAt time.Time +} + +type ListRowsOutput struct { + Rows []RowItem + TotalCount int64 +} + +type GetRowInput struct { + UserId string + DatabaseId string + RowId string +} + +type GetRowOutput struct { + Id string + DatabaseId string + Properties map[string]interface{} + Content map[string]interface{} + ShowInSidebar bool + CreatedBy string + CreatedByUser *UserInfo + UpdatedBy string + UpdatedByUser *UserInfo + CreatedAt time.Time + UpdatedAt time.Time +} + +type UpdateRowInput struct { + UserId string + DatabaseId string + RowId string + Properties map[string]interface{} + Content map[string]interface{} + ShowInSidebar *bool +} + +type DeleteRowInput struct { + UserId string + DatabaseId string + RowId string +} + +type BulkDeleteRowsInput struct { + UserId string + DatabaseId string + RowIds []string +} + +// Move database +type MoveDatabaseInput struct { + UserId string + DatabaseId string + DocumentId *string // nil = move to root (no parent document) +} + +type MoveDatabaseOutput struct { + Id string + DocumentId *string +} + +// Search databases +type SearchDatabasesInput struct { + UserId string + Query string + SpaceId *string + Limit int +} + +type SearchDatabaseResultItem struct { + Id string + Name string + Description string + Icon string + Type string + SpaceId string + SpaceName string + UpdatedAt time.Time +} + +type SearchDatabasesOutput struct { + Results []SearchDatabaseResultItem +} diff --git a/application/database/dto/permissions.go b/application/database/dto/permissions.go new file mode 100644 index 0000000..5f9976d --- /dev/null +++ b/application/database/dto/permissions.go @@ -0,0 +1,38 @@ +package dto + +// Permission item for listing +type DatabasePermissionItem struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + GroupId *string `json:"group_id,omitempty"` + GroupName *string `json:"group_name,omitempty"` + Role string `json:"role"` +} + +// List permissions +type ListDatabasePermissionsInput struct { + UserId string + DatabaseId string +} + +type ListDatabasePermissionsOutput struct { + Permissions []DatabasePermissionItem `json:"permissions"` +} + +// Upsert permission +type UpsertDatabasePermissionInput struct { + UserId string // The user making the request + DatabaseId string // The database to add permission to + TargetUserId *string // The user to add permission for (mutually exclusive with GroupId) + GroupId *string // The group to add permission for (mutually exclusive with TargetUserId) + Role string // editor, viewer, denied +} + +// Delete permission +type DeleteDatabasePermissionInput struct { + UserId string // The user making the request + DatabaseId string // The database + TargetUserId *string // The user to remove permission from (mutually exclusive with GroupId) + GroupId *string // The group to remove permission from (mutually exclusive with TargetUserId) +} diff --git a/application/database/permissions.go b/application/database/permissions.go new file mode 100644 index 0000000..7048bf3 --- /dev/null +++ b/application/database/permissions.go @@ -0,0 +1,149 @@ +package database + +import ( + "fmt" + + "github.com/labbs/nexo/application/database/dto" + "github.com/labbs/nexo/domain" +) + +// ListDatabasePermissions returns all permissions for a database +func (app *DatabaseApp) ListDatabasePermissions(input dto.ListDatabasePermissionsInput) (*dto.ListDatabasePermissionsOutput, error) { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("database not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + perms, err := app.PermissionPers.ListByResource(domain.PermissionTypeDatabase, input.DatabaseId) + if err != nil { + return nil, fmt.Errorf("failed to list permissions: %w", err) + } + + output := &dto.ListDatabasePermissionsOutput{ + Permissions: make([]dto.DatabasePermissionItem, len(perms)), + } + + for i, perm := range perms { + item := dto.DatabasePermissionItem{ + Id: perm.Id, + Role: string(perm.Role), + } + if perm.UserId != nil { + item.UserId = perm.UserId + if perm.User != nil { + item.Username = &perm.User.Username + } + } + if perm.GroupId != nil { + item.GroupId = perm.GroupId + if perm.Group != nil { + item.GroupName = &perm.Group.Name + } + } + output.Permissions[i] = item + } + + return output, nil +} + +// UpsertDatabasePermission adds or updates a permission for a database +func (app *DatabaseApp) UpsertDatabasePermission(input dto.UpsertDatabasePermissionInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has permission to manage permissions (creator or space admin) + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + spaceRole := space.GetUserRole(input.UserId) + if spaceRole == nil { + return fmt.Errorf("access denied") + } + + // Only creator or space admin/owner can manage permissions + isCreator := database.CreatedBy == input.UserId + isSpaceAdmin := *spaceRole == domain.PermissionRoleOwner || *spaceRole == domain.PermissionRoleAdmin + if !isCreator && !isSpaceAdmin { + return fmt.Errorf("only creator or space admins can manage permissions") + } + + // Validate role + role := domain.PermissionRole(input.Role) + if role != domain.PermissionRoleEditor && role != domain.PermissionRoleViewer && role != domain.PermissionRoleDenied { + return fmt.Errorf("invalid role: %s", input.Role) + } + + // Validate that either UserId or GroupId is provided, but not both + if (input.TargetUserId == nil && input.GroupId == nil) || (input.TargetUserId != nil && input.GroupId != nil) { + return fmt.Errorf("must provide either user_id or group_id, but not both") + } + + if input.TargetUserId != nil { + if err := app.PermissionPers.UpsertUser(domain.PermissionTypeDatabase, input.DatabaseId, *input.TargetUserId, role); err != nil { + return fmt.Errorf("failed to upsert permission: %w", err) + } + } else { + if err := app.PermissionPers.UpsertGroup(domain.PermissionTypeDatabase, input.DatabaseId, *input.GroupId, role); err != nil { + return fmt.Errorf("failed to upsert permission: %w", err) + } + } + + return nil +} + +// DeleteDatabasePermission removes a permission from a database +func (app *DatabaseApp) DeleteDatabasePermission(input dto.DeleteDatabasePermissionInput) error { + database, err := app.DatabasePers.GetById(input.DatabaseId) + if err != nil { + return fmt.Errorf("database not found: %w", err) + } + + // Verify user has permission to manage permissions (creator or space admin) + space, err := app.SpacePers.GetSpaceById(database.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + spaceRole := space.GetUserRole(input.UserId) + if spaceRole == nil { + return fmt.Errorf("access denied") + } + + // Only creator or space admin/owner can manage permissions + isCreator := database.CreatedBy == input.UserId + isSpaceAdmin := *spaceRole == domain.PermissionRoleOwner || *spaceRole == domain.PermissionRoleAdmin + if !isCreator && !isSpaceAdmin { + return fmt.Errorf("only creator or space admins can manage permissions") + } + + // Validate that either UserId or GroupId is provided, but not both + if (input.TargetUserId == nil && input.GroupId == nil) || (input.TargetUserId != nil && input.GroupId != nil) { + return fmt.Errorf("must provide either user_id or group_id, but not both") + } + + if input.TargetUserId != nil { + if err := app.PermissionPers.DeleteUser(domain.PermissionTypeDatabase, input.DatabaseId, *input.TargetUserId); err != nil { + return fmt.Errorf("failed to delete permission: %w", err) + } + } else { + if err := app.PermissionPers.DeleteGroup(domain.PermissionTypeDatabase, input.DatabaseId, *input.GroupId); err != nil { + return fmt.Errorf("failed to delete permission: %w", err) + } + } + + return nil +} diff --git a/application/document/comment.go b/application/document/comment.go new file mode 100644 index 0000000..4aba44a --- /dev/null +++ b/application/document/comment.go @@ -0,0 +1,129 @@ +package document + +import ( + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +func (app *DocumentApp) CreateComment(input dto.CreateCommentInput) (*dto.CreateCommentOutput, error) { + // Verify user has access to the document + _, err := app.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions("", &input.DocumentId, nil, input.UserId) + if err != nil { + return nil, fmt.Errorf("document not found or access denied: %w", err) + } + + comment := &domain.Comment{ + Id: uuid.New().String(), + DocumentId: input.DocumentId, + UserId: input.UserId, + ParentId: input.ParentId, + Content: input.Content, + BlockId: input.BlockId, + Resolved: false, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.CommentPers.Create(comment); err != nil { + return nil, fmt.Errorf("failed to create comment: %w", err) + } + + return &dto.CreateCommentOutput{ + CommentId: comment.Id, + }, nil +} + +func (app *DocumentApp) GetComments(input dto.GetCommentsInput) (*dto.GetCommentsOutput, error) { + // Verify user has access to the document + _, err := app.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions("", &input.DocumentId, nil, input.UserId) + if err != nil { + return nil, fmt.Errorf("document not found or access denied: %w", err) + } + + comments, err := app.CommentPers.GetByDocumentId(input.DocumentId) + if err != nil { + return nil, fmt.Errorf("failed to get comments: %w", err) + } + + output := &dto.GetCommentsOutput{ + Comments: make([]dto.CommentOutput, len(comments)), + } + + for i, c := range comments { + output.Comments[i] = dto.CommentOutput{ + Id: c.Id, + UserId: c.UserId, + UserName: c.User.Username, + ParentId: c.ParentId, + Content: c.Content, + BlockId: c.BlockId, + Resolved: c.Resolved, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + } + + return output, nil +} + +func (app *DocumentApp) UpdateComment(input dto.UpdateCommentInput) error { + comment, err := app.CommentPers.GetById(input.CommentId) + if err != nil { + return fmt.Errorf("comment not found: %w", err) + } + + // Only the comment author can update it + if comment.UserId != input.UserId { + return fmt.Errorf("access denied: only the comment author can update it") + } + + comment.Content = input.Content + comment.UpdatedAt = time.Now() + + if err := app.CommentPers.Update(comment); err != nil { + return fmt.Errorf("failed to update comment: %w", err) + } + + return nil +} + +func (app *DocumentApp) DeleteComment(input dto.DeleteCommentInput) error { + comment, err := app.CommentPers.GetById(input.CommentId) + if err != nil { + return fmt.Errorf("comment not found: %w", err) + } + + // Only the comment author can delete it + if comment.UserId != input.UserId { + return fmt.Errorf("access denied: only the comment author can delete it") + } + + if err := app.CommentPers.Delete(input.CommentId); err != nil { + return fmt.Errorf("failed to delete comment: %w", err) + } + + return nil +} + +func (app *DocumentApp) ResolveComment(input dto.ResolveCommentInput) error { + comment, err := app.CommentPers.GetById(input.CommentId) + if err != nil { + return fmt.Errorf("comment not found: %w", err) + } + + // Verify user has access to the document (any user with document access can resolve) + _, err = app.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions("", &comment.DocumentId, nil, input.UserId) + if err != nil { + return fmt.Errorf("access denied: %w", err) + } + + if err := app.CommentPers.Resolve(input.CommentId, input.Resolved); err != nil { + return fmt.Errorf("failed to resolve comment: %w", err) + } + + return nil +} diff --git a/application/document/create.go b/application/document/create.go new file mode 100644 index 0000000..82021d1 --- /dev/null +++ b/application/document/create.go @@ -0,0 +1,79 @@ +package document + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/gosimple/slug" + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/shortuuid" +) + +func (a *DocumentApp) CreateDocument(input dto.CreateDocumentInput) (*dto.CreateDocumentOutput, error) { + logger := a.Logger.With().Str("component", "application.document.create_document").Logger() + + var space *domain.Space + + if input.ParentId != nil { + parent, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, input.ParentId, nil, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get parent document") + return nil, fmt.Errorf("failed to get parent document: %w", err) + } + + if !parent.HasPermission(input.UserId, domain.PermissionRoleEditor) { + logger.Error().Msg("user does not have permission to create document under the specified parent") + return nil, fmt.Errorf("user does not have permission to create document under the specified parent") + } + + // Get the space for the response + space, err = a.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to get space for document creation") + return nil, fmt.Errorf("failed to get space for document creation: %w", err) + } + } else { + var err error + space, err = a.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to get space for document creation") + return nil, fmt.Errorf("failed to get space for document creation: %w", err) + } + + if !space.HasPermission(input.UserId, domain.PermissionRoleEditor) { + logger.Error().Msg("user does not have permission to create document in the specified space") + return nil, fmt.Errorf("user does not have permission to create document in the specified space") + } + } + + document := &domain.Document{ + Id: utils.UUIDv4(), + Name: input.Name, + Slug: slug.Make(input.Name + "-" + shortuuid.GenerateShortUUID()), + SpaceId: input.SpaceId, + Content: dto.BlocksToJSON(input.Content), + ParentId: input.ParentId, + Public: false, + } + + err := a.DocumentPers.Create(document, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to create document") + return nil, fmt.Errorf("failed to create document: %w", err) + } + + // Auto-create owner permission for the creator + // This ensures they retain access and can manage permissions even if their space role is downgraded + if err := a.PermissionPers.UpsertUser(domain.PermissionTypeDocument, document.Id, input.UserId, domain.PermissionRoleOwner); err != nil { + // Log but don't fail - the document is already created + logger.Warn().Err(err).Str("document_id", document.Id).Str("user_id", input.UserId).Msg("failed to create creator permission") + } + + // Assign the space to the document for the response + if space != nil { + document.Space = *space + } + + return &dto.CreateDocumentOutput{Document: document}, nil +} diff --git a/application/document/delete.go b/application/document/delete.go new file mode 100644 index 0000000..c7e31f7 --- /dev/null +++ b/application/document/delete.go @@ -0,0 +1,34 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +func (a *DocumentApp) DeleteDocument(input dto.DeleteDocumentInput) error { + logger := a.Logger.With().Str("component", "application.document.delete_document").Logger() + + if input.DocumentId == nil && input.Slug == nil { + return fmt.Errorf("either documentId or slug must be provided") + } + + document, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, input.DocumentId, input.Slug, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get document for delete") + return fmt.Errorf("failed to get document for delete: %w", err) + } + + if !document.HasPermission(input.UserId, domain.PermissionRoleEditor) { + logger.Error().Msg("user does not have permission to delete document") + return fmt.Errorf("user does not have permission to delete document") + } + + if err := a.DocumentPers.Delete(document.Id, input.UserId); err != nil { + logger.Error().Err(err).Msg("failed to delete document") + return err + } + + return nil +} diff --git a/application/document/document.go b/application/document/document.go new file mode 100644 index 0000000..25ffb06 --- /dev/null +++ b/application/document/document.go @@ -0,0 +1,29 @@ +package document + +import ( + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type DocumentApp struct { + Config config.Config + Logger zerolog.Logger + DocumentPers domain.DocumentPers + SpacePers domain.SpacePers + PermissionPers domain.PermissionPers + CommentPers domain.CommentPers + DocumentVersionPers domain.DocumentVersionPers +} + +func NewDocumentApp(config config.Config, logger zerolog.Logger, documentPers domain.DocumentPers, spacePers domain.SpacePers, permissionPers domain.PermissionPers, commentPers domain.CommentPers, documentVersionPers domain.DocumentVersionPers) *DocumentApp { + return &DocumentApp{ + Config: config, + Logger: logger, + DocumentPers: documentPers, + SpacePers: spacePers, + PermissionPers: permissionPers, + CommentPers: commentPers, + DocumentVersionPers: documentVersionPers, + } +} diff --git a/application/document/dto/comment.go b/application/document/dto/comment.go new file mode 100644 index 0000000..112540f --- /dev/null +++ b/application/document/dto/comment.go @@ -0,0 +1,58 @@ +package dto + +import "time" + +// Create comment +type CreateCommentInput struct { + UserId string + DocumentId string + ParentId *string + Content string + BlockId *string +} + +type CreateCommentOutput struct { + CommentId string +} + +// Get comments +type GetCommentsInput struct { + UserId string + DocumentId string +} + +type CommentOutput struct { + Id string + UserId string + UserName string + ParentId *string + Content string + BlockId *string + Resolved bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type GetCommentsOutput struct { + Comments []CommentOutput +} + +// Update comment +type UpdateCommentInput struct { + UserId string + CommentId string + Content string +} + +// Delete comment +type DeleteCommentInput struct { + UserId string + CommentId string +} + +// Resolve comment +type ResolveCommentInput struct { + UserId string + CommentId string + Resolved bool +} diff --git a/application/document/dto/create.go b/application/document/dto/create.go new file mode 100644 index 0000000..50a220f --- /dev/null +++ b/application/document/dto/create.go @@ -0,0 +1,15 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type CreateDocumentInput struct { + Name string + UserId string + SpaceId string + Content []Block + ParentId *string +} + +type CreateDocumentOutput struct { + Document *domain.Document +} diff --git a/application/document/dto/delete.go b/application/document/dto/delete.go new file mode 100644 index 0000000..2c669e8 --- /dev/null +++ b/application/document/dto/delete.go @@ -0,0 +1,8 @@ +package dto + +type DeleteDocumentInput struct { + UserId string + SpaceId string + DocumentId *string + Slug *string +} diff --git a/application/document/dto/document.go b/application/document/dto/document.go new file mode 100644 index 0000000..50cce82 --- /dev/null +++ b/application/document/dto/document.go @@ -0,0 +1,90 @@ +package dto + +import ( + "encoding/json" + "time" + + "gorm.io/datatypes" +) + +// Document représente un document dans la couche application +type Document struct { + Id string + Name string + Slug string + ParentId *string + SpaceId string + Public bool + Content []Block + Config DocumentConfig + Metadata map[string]any + + CreatedAt time.Time + UpdatedAt time.Time +} + +// DocumentConfig représente la configuration d'un document +type DocumentConfig struct { + FullWidth bool `json:"fullWidth"` + Icon string `json:"icon"` + Lock bool `json:"lock"` + HeaderBackground string `json:"headerBackground"` +} + +// Block représente un bloc BlockNote +type Block struct { + ID string `json:"id"` + Type string `json:"type"` // paragraph, heading, bulletListItem, table, etc. + Props map[string]any `json:"props"` + Content []InlineContent `json:"content"` + Children []Block `json:"children"` +} + +// InlineContent représente le contenu inline (texte, liens, etc.) +type InlineContent struct { + Type string `json:"type"` // "text", "link" + Text string `json:"text,omitempty"` + Href string `json:"href,omitempty"` + Styles map[string]bool `json:"styles"` +} + +// Types de blocs BlockNote supportés +const ( + BlockTypeParagraph = "paragraph" + BlockTypeHeading = "heading" + BlockTypeBulletListItem = "bulletListItem" + BlockTypeNumberedListItem = "numberedListItem" + BlockTypeCheckListItem = "checkListItem" + BlockTypeTable = "table" + BlockTypeImage = "image" + BlockTypeVideo = "video" + BlockTypeAudio = "audio" + BlockTypeFile = "file" + BlockTypeCodeBlock = "codeBlock" + BlockTypeColumn = "column" + BlockTypeColumnList = "columnList" +) + +// BlocksToJSON convertit []Block en datatypes.JSON pour le stockage +func BlocksToJSON(blocks []Block) datatypes.JSON { + if blocks == nil { + return datatypes.JSON("[]") + } + data, err := json.Marshal(blocks) + if err != nil { + return datatypes.JSON("[]") + } + return datatypes.JSON(data) +} + +// JSONToBlocks convertit datatypes.JSON en []Block +func JSONToBlocks(data datatypes.JSON) []Block { + if data == nil || len(data) == 0 { + return []Block{} + } + var blocks []Block + if err := json.Unmarshal(data, &blocks); err != nil { + return []Block{} + } + return blocks +} diff --git a/application/document/dto/get.go b/application/document/dto/get.go new file mode 100644 index 0000000..2606912 --- /dev/null +++ b/application/document/dto/get.go @@ -0,0 +1,24 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type GetDocumentWithSpaceInput struct { + UserId string + SpaceId string + DocumentId *string + Slug *string +} + +type GetDocumentWithSpaceOutput struct { + Document *domain.Document +} + +type GetDocumentsFromSpaceInput struct { + SpaceId string + UserId string + ParentId *string +} + +type GetDocumentsFromSpaceOutput struct { + Documents []domain.Document +} diff --git a/application/document/dto/move.go b/application/document/dto/move.go new file mode 100644 index 0000000..1d3d583 --- /dev/null +++ b/application/document/dto/move.go @@ -0,0 +1,14 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type MoveDocumentInput struct { + UserId string + SpaceId string + DocumentId string + NewParentId *string +} + +type MoveDocumentOutput struct { + Document *domain.Document +} diff --git a/application/document/dto/permissions.go b/application/document/dto/permissions.go new file mode 100644 index 0000000..36b4fd0 --- /dev/null +++ b/application/document/dto/permissions.go @@ -0,0 +1,28 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type ListDocumentPermissionsInput struct { + RequesterId string + SpaceId string + DocumentId string +} + +type ListDocumentPermissionsOutput struct { + Permissions []domain.Permission +} + +type UpsertDocumentUserPermissionInput struct { + RequesterId string + SpaceId string + DocumentId string + TargetUserId string + Role domain.PermissionRole +} + +type DeleteDocumentUserPermissionInput struct { + RequesterId string + SpaceId string + DocumentId string + TargetUserId string +} diff --git a/application/document/dto/public.go b/application/document/dto/public.go new file mode 100644 index 0000000..f512bc3 --- /dev/null +++ b/application/document/dto/public.go @@ -0,0 +1,20 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type SetPublicInput struct { + UserId string + SpaceId string + DocumentId string + Public bool +} + +type GetPublicDocumentInput struct { + SpaceId string + DocumentId *string + Slug *string +} + +type GetPublicDocumentOutput struct { + Document *domain.Document +} diff --git a/application/document/dto/reorder.go b/application/document/dto/reorder.go new file mode 100644 index 0000000..3230e83 --- /dev/null +++ b/application/document/dto/reorder.go @@ -0,0 +1,12 @@ +package dto + +type ReorderItem struct { + Id string + Position int +} + +type ReorderDocumentsInput struct { + UserId string + SpaceId string + Items []ReorderItem +} diff --git a/application/document/dto/search.go b/application/document/dto/search.go new file mode 100644 index 0000000..c493b7b --- /dev/null +++ b/application/document/dto/search.go @@ -0,0 +1,24 @@ +package dto + +import "time" + +type SearchInput struct { + UserId string + Query string + SpaceId *string + Limit int +} + +type SearchResultItem struct { + Id string + Name string + Slug string + SpaceId string + SpaceName string + Icon string + UpdatedAt time.Time +} + +type SearchOutput struct { + Results []SearchResultItem +} diff --git a/application/document/dto/trash.go b/application/document/dto/trash.go new file mode 100644 index 0000000..92cfd23 --- /dev/null +++ b/application/document/dto/trash.go @@ -0,0 +1,18 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type GetTrashInput struct { + UserId string + SpaceId string +} + +type GetTrashOutput struct { + Documents []domain.Document +} + +type RestoreDocumentInput struct { + UserId string + SpaceId string + DocumentId string +} diff --git a/application/document/dto/update.go b/application/document/dto/update.go new file mode 100644 index 0000000..bbd0daa --- /dev/null +++ b/application/document/dto/update.go @@ -0,0 +1,18 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type UpdateDocumentInput struct { + UserId string + SpaceId string + DocumentId string + Name *string + Content *[]Block + ParentId *string + Config *domain.DocumentConfig + Metadata *domain.JSONB +} + +type UpdateDocumentOutput struct { + Document *domain.Document +} diff --git a/application/document/dto/version.go b/application/document/dto/version.go new file mode 100644 index 0000000..da8876c --- /dev/null +++ b/application/document/dto/version.go @@ -0,0 +1,64 @@ +package dto + +import "time" + +// List versions +type ListVersionsInput struct { + UserId string + SpaceId string + DocumentId string + Limit int + Offset int +} + +type VersionItem struct { + Id string + Version int + Name string + Description string + UserId string + UserName string + CreatedAt time.Time +} + +type ListVersionsOutput struct { + Versions []VersionItem + TotalCount int64 +} + +// Get version +type GetVersionInput struct { + UserId string + VersionId string +} + +type GetVersionOutput struct { + Id string + Version int + DocumentId string + Name string + Content []Block + Config DocumentConfig + Description string + UserId string + UserName string + CreatedAt time.Time +} + +// Restore version +type RestoreVersionInput struct { + UserId string + VersionId string +} + +// Create version manually +type CreateVersionInput struct { + UserId string + DocumentId string + Description string +} + +type CreateVersionOutput struct { + VersionId string + Version int +} diff --git a/application/document/get.go b/application/document/get.go new file mode 100644 index 0000000..7cd2442 --- /dev/null +++ b/application/document/get.go @@ -0,0 +1,44 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +func (a *DocumentApp) GetDocumentWithSpace(input dto.GetDocumentWithSpaceInput) (*dto.GetDocumentWithSpaceOutput, error) { + logger := a.Logger.With().Str("component", "application.document.get_document").Logger() + + if input.DocumentId == nil && input.Slug == nil { + return nil, fmt.Errorf("either documentId or slug must be provided") + } + + document, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, input.DocumentId, input.Slug, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get document") + return nil, err + } + + return &dto.GetDocumentWithSpaceOutput{Document: document}, nil +} + +func (a DocumentApp) GetDocumentsFromSpaceWithUserPermissions(input dto.GetDocumentsFromSpaceInput) (*dto.GetDocumentsFromSpaceOutput, error) { + logger := a.Logger.With().Str("component", "application.document.get_documents_from_space").Logger() + + var documents []domain.Document + var err error + + if input.ParentId != nil { + documents, err = a.DocumentPers.GetChildDocumentsWithUserPermissions(*input.ParentId, input.UserId) + } else { + documents, err = a.DocumentPers.GetRootDocumentsFromSpaceWithUserPermissions(input.SpaceId, input.UserId) + } + + if err != nil { + logger.Error().Err(err).Msg("failed to get documents from space") + return nil, err + } + + return &dto.GetDocumentsFromSpaceOutput{Documents: documents}, nil +} diff --git a/application/document/move.go b/application/document/move.go new file mode 100644 index 0000000..e4dabea --- /dev/null +++ b/application/document/move.go @@ -0,0 +1,25 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" +) + +func (a *DocumentApp) MoveDocument(input dto.MoveDocumentInput) (*dto.MoveDocumentOutput, error) { + logger := a.Logger.With().Str("component", "application.document.move_document").Logger() + + // Ensure the document belongs to the provided space and user has access + doc, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get document for move") + return nil, fmt.Errorf("failed to get document for move: %w", err) + } + // Delegate to persistence Move (permission checks and save) + moved, err := a.DocumentPers.Move(doc.Id, input.NewParentId, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to move document") + return nil, err + } + return &dto.MoveDocumentOutput{Document: moved}, nil +} diff --git a/application/document/permissions.go b/application/document/permissions.go new file mode 100644 index 0000000..ca109f1 --- /dev/null +++ b/application/document/permissions.go @@ -0,0 +1,70 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +func (c *DocumentApp) ListDocumentPermissions(input dto.ListDocumentPermissionsInput) (*dto.ListDocumentPermissionsOutput, error) { + // Get the document with space to check permissions + doc, err := c.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.RequesterId) + if err != nil || doc == nil { + return nil, fmt.Errorf("not_found") + } + + // User must have at least viewer access to the document to see permissions + if !doc.HasPermission(input.RequesterId, domain.PermissionRoleViewer) { + return nil, fmt.Errorf("forbidden") + } + + permissions, err := c.PermissionPers.ListByResource(domain.PermissionTypeDocument, input.DocumentId) + if err != nil { + return nil, err + } + + return &dto.ListDocumentPermissionsOutput{Permissions: permissions}, nil +} + +func (c *DocumentApp) UpsertDocumentUserPermission(input dto.UpsertDocumentUserPermissionInput) error { + // Get the document with space to check permissions + doc, err := c.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.RequesterId) + if err != nil || doc == nil { + return fmt.Errorf("not_found") + } + + // User must be able to manage permissions (owner of document OR admin of space) + if !doc.CanManagePermissions(input.RequesterId) { + return fmt.Errorf("forbidden") + } + + // Prevent changing the owner's role on documents in personal/private spaces + if (doc.Space.Type == domain.SpaceTypePersonal || doc.Space.Type == domain.SpaceTypePrivate) && + doc.Space.OwnerId != nil && *doc.Space.OwnerId == input.TargetUserId && input.Role != domain.PermissionRoleOwner { + return fmt.Errorf("cannot_change_owner_role") + } + + return c.PermissionPers.UpsertUser(domain.PermissionTypeDocument, input.DocumentId, input.TargetUserId, input.Role) +} + +func (c *DocumentApp) DeleteDocumentUserPermission(input dto.DeleteDocumentUserPermissionInput) error { + // Get the document with space to check permissions + doc, err := c.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.RequesterId) + if err != nil || doc == nil { + return fmt.Errorf("not_found") + } + + // User must be able to manage permissions (owner of document OR admin of space) + if !doc.CanManagePermissions(input.RequesterId) { + return fmt.Errorf("forbidden") + } + + // Prevent removing the space owner from documents in personal/private spaces + if (doc.Space.Type == domain.SpaceTypePersonal || doc.Space.Type == domain.SpaceTypePrivate) && + doc.Space.OwnerId != nil && *doc.Space.OwnerId == input.TargetUserId { + return fmt.Errorf("cannot_remove_owner") + } + + return c.PermissionPers.DeleteUser(domain.PermissionTypeDocument, input.DocumentId, input.TargetUserId) +} diff --git a/application/document/public.go b/application/document/public.go new file mode 100644 index 0000000..1a48a14 --- /dev/null +++ b/application/document/public.go @@ -0,0 +1,31 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" +) + +func (c *DocumentApp) SetPublic(input dto.SetPublicInput) error { + logger := c.Logger.With().Str("component", "application.document.set_public").Logger() + + err := c.DocumentPers.SetPublic(input.DocumentId, input.Public, input.UserId) + if err != nil { + logger.Error().Err(err).Str("document_id", input.DocumentId).Msg("failed to set document public status") + return err + } + + return nil +} + +func (c *DocumentApp) GetPublicDocument(input dto.GetPublicDocumentInput) (*dto.GetPublicDocumentOutput, error) { + logger := c.Logger.With().Str("component", "application.document.get_public_document").Logger() + + doc, err := c.DocumentPers.GetPublicDocument(input.SpaceId, input.DocumentId, input.Slug) + if err != nil { + logger.Error().Err(err).Msg("failed to get public document") + return nil, fmt.Errorf("document not found or not public") + } + + return &dto.GetPublicDocumentOutput{Document: doc}, nil +} diff --git a/application/document/reorder.go b/application/document/reorder.go new file mode 100644 index 0000000..b6a8dbf --- /dev/null +++ b/application/document/reorder.go @@ -0,0 +1,32 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +func (a *DocumentApp) ReorderDocuments(input dto.ReorderDocumentsInput) error { + logger := a.Logger.With().Str("component", "application.document.reorder_documents").Logger() + + if len(input.Items) == 0 { + return fmt.Errorf("no items to reorder") + } + + // Convert application DTOs to domain items + domainItems := make([]domain.ReorderItem, len(input.Items)) + for i, item := range input.Items { + domainItems[i] = domain.ReorderItem{ + Id: item.Id, + Position: item.Position, + } + } + + if err := a.DocumentPers.Reorder(input.SpaceId, domainItems, input.UserId); err != nil { + logger.Error().Err(err).Msg("failed to reorder documents") + return err + } + + return nil +} diff --git a/application/document/search.go b/application/document/search.go new file mode 100644 index 0000000..f2423a1 --- /dev/null +++ b/application/document/search.go @@ -0,0 +1,36 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" +) + +func (app *DocumentApp) Search(input dto.SearchInput) (*dto.SearchOutput, error) { + if len(input.Query) < 2 { + return nil, fmt.Errorf("query must be at least 2 characters") + } + + docs, err := app.DocumentPers.Search(input.Query, input.UserId, input.SpaceId, input.Limit) + if err != nil { + return nil, fmt.Errorf("failed to search documents: %w", err) + } + + output := &dto.SearchOutput{ + Results: make([]dto.SearchResultItem, len(docs)), + } + + for i, doc := range docs { + output.Results[i] = dto.SearchResultItem{ + Id: doc.Id, + Name: doc.Name, + Slug: doc.Slug, + SpaceId: doc.SpaceId, + SpaceName: doc.Space.Name, + Icon: doc.Config.Icon, + UpdatedAt: doc.UpdatedAt, + } + } + + return output, nil +} diff --git a/application/document/trash.go b/application/document/trash.go new file mode 100644 index 0000000..7a5e04b --- /dev/null +++ b/application/document/trash.go @@ -0,0 +1,31 @@ +package document + +import ( + "fmt" + + "github.com/labbs/nexo/application/document/dto" +) + +func (c *DocumentApp) GetTrash(input dto.GetTrashInput) (*dto.GetTrashOutput, error) { + logger := c.Logger.With().Str("component", "application.document.get_trash").Logger() + + docs, err := c.DocumentPers.GetDeletedDocuments(input.SpaceId, input.UserId) + if err != nil { + logger.Error().Err(err).Str("space_id", input.SpaceId).Msg("failed to get deleted documents") + return nil, fmt.Errorf("failed to get trash") + } + + return &dto.GetTrashOutput{Documents: docs}, nil +} + +func (c *DocumentApp) RestoreDocument(input dto.RestoreDocumentInput) error { + logger := c.Logger.With().Str("component", "application.document.restore_document").Logger() + + err := c.DocumentPers.Restore(input.DocumentId, input.UserId) + if err != nil { + logger.Error().Err(err).Str("document_id", input.DocumentId).Msg("failed to restore document") + return err + } + + return nil +} diff --git a/application/document/update.go b/application/document/update.go new file mode 100644 index 0000000..428934d --- /dev/null +++ b/application/document/update.go @@ -0,0 +1,69 @@ +package document + +import ( + "fmt" + + "github.com/gosimple/slug" + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/shortuuid" +) + +func (a *DocumentApp) UpdateDocument(input dto.UpdateDocumentInput) (*dto.UpdateDocumentOutput, error) { + logger := a.Logger.With().Str("component", "application.document.update_document").Logger() + + document, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get document for update") + return nil, fmt.Errorf("failed to get document for update: %w", err) + } + + if !document.HasPermission(input.UserId, domain.PermissionRoleEditor) { + logger.Error().Msg("user does not have permission to update document") + return nil, fmt.Errorf("user does not have permission to update document") + } + + // Update name only if provided + if input.Name != nil && *input.Name != "" && document.Name != *input.Name { + document.Name = *input.Name + document.Slug = slug.Make(*input.Name + "-" + shortuuid.GenerateShortUUID()) + } + + // Update content only if provided + if input.Content != nil { + document.Content = dto.BlocksToJSON(*input.Content) + } + + // Update parentId if provided + if input.ParentId != nil { + // If parentId is empty string, set to nil (move to root) + if *input.ParentId == "" { + document.ParentId = nil + } else { + document.ParentId = input.ParentId + } + } + + // Update config if provided + if input.Config != nil { + document.Config = *input.Config + } + + // Update metadata if provided + if input.Metadata != nil { + document.Metadata = domain.JSONB(*input.Metadata) + } + + // Create a version snapshot before updating (for content changes) + if input.Content != nil { + a.CreateVersionOnUpdate(document, input.UserId) + } + + err = a.DocumentPers.Update(document, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to update document") + return nil, fmt.Errorf("failed to update document: %w", err) + } + + return &dto.UpdateDocumentOutput{Document: document}, nil +} diff --git a/application/document/version.go b/application/document/version.go new file mode 100644 index 0000000..8e65acd --- /dev/null +++ b/application/document/version.go @@ -0,0 +1,192 @@ +package document + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" +) + +const MaxVersionsPerDocument = 50 + +func (app *DocumentApp) ListVersions(input dto.ListVersionsInput) (*dto.ListVersionsOutput, error) { + // Verify user has access to the document (accept ID or slug) + doc, err := app.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, &input.DocumentId, input.UserId) + if err != nil { + return nil, fmt.Errorf("document not found or access denied: %w", err) + } + + versions, err := app.DocumentVersionPers.GetByDocumentId(doc.Id, input.Limit, input.Offset) + if err != nil { + return nil, fmt.Errorf("failed to get versions: %w", err) + } + + totalCount, err := app.DocumentVersionPers.GetVersionCount(doc.Id) + if err != nil { + return nil, fmt.Errorf("failed to get version count: %w", err) + } + + output := &dto.ListVersionsOutput{ + Versions: make([]dto.VersionItem, len(versions)), + TotalCount: totalCount, + } + + for i, v := range versions { + output.Versions[i] = dto.VersionItem{ + Id: v.Id, + Version: v.Version, + Name: v.Name, + Description: v.Description, + UserId: v.UserId, + UserName: v.User.Username, + CreatedAt: v.CreatedAt, + } + } + + return output, nil +} + +func (app *DocumentApp) GetVersion(input dto.GetVersionInput) (*dto.GetVersionOutput, error) { + version, err := app.DocumentVersionPers.GetById(input.VersionId) + if err != nil { + return nil, fmt.Errorf("version not found: %w", err) + } + + // Verify user has access to the document + _, err = app.DocumentPers.GetDocumentWithPermissions(version.DocumentId, input.UserId) + if err != nil { + return nil, fmt.Errorf("access denied: %w", err) + } + + // Parse content + var blocks []dto.Block + if version.Content != nil { + if err := json.Unmarshal(version.Content, &blocks); err != nil { + blocks = []dto.Block{} + } + } + + return &dto.GetVersionOutput{ + Id: version.Id, + Version: version.Version, + DocumentId: version.DocumentId, + Name: version.Name, + Content: blocks, + Config: dto.DocumentConfig{ + FullWidth: version.Config.FullWidth, + Icon: version.Config.Icon, + Lock: version.Config.Lock, + HeaderBackground: version.Config.HeaderBackground, + }, + Description: version.Description, + UserId: version.UserId, + UserName: version.User.Username, + CreatedAt: version.CreatedAt, + }, nil +} + +func (app *DocumentApp) RestoreVersion(input dto.RestoreVersionInput) error { + version, err := app.DocumentVersionPers.GetById(input.VersionId) + if err != nil { + return fmt.Errorf("version not found: %w", err) + } + + // Verify user has editor access to the document + doc, err := app.DocumentPers.GetDocumentWithPermissions(version.DocumentId, input.UserId) + if err != nil { + return fmt.Errorf("access denied: %w", err) + } + + if !doc.HasPermission(input.UserId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions") + } + + // Create a new version before restoring (to preserve current state) + if _, err := app.createVersionFromDocument(doc, input.UserId, fmt.Sprintf("Before restore to version %d", version.Version)); err != nil { + return fmt.Errorf("failed to create backup version: %w", err) + } + + // Restore the document to the selected version + doc.Name = version.Name + doc.Content = version.Content + doc.Config = version.Config + + if err := app.DocumentPers.Update(doc, input.UserId); err != nil { + return fmt.Errorf("failed to restore document: %w", err) + } + + return nil +} + +func (app *DocumentApp) CreateVersion(input dto.CreateVersionInput) (*dto.CreateVersionOutput, error) { + // Verify user has editor access to the document + doc, err := app.DocumentPers.GetDocumentWithPermissions(input.DocumentId, input.UserId) + if err != nil { + return nil, fmt.Errorf("document not found or access denied: %w", err) + } + + if !doc.HasPermission(input.UserId, domain.PermissionRoleEditor) { + return nil, fmt.Errorf("access denied: insufficient permissions") + } + + version, err := app.createVersionFromDocument(doc, input.UserId, input.Description) + if err != nil { + return nil, err + } + + return &dto.CreateVersionOutput{ + VersionId: version.Id, + Version: version.Version, + }, nil +} + +// createVersionFromDocument creates a new version snapshot of a document +func (app *DocumentApp) createVersionFromDocument(doc *domain.Document, userId string, description string) (*domain.DocumentVersion, error) { + // Get the next version number + latestVersion, err := app.DocumentVersionPers.GetLatestVersion(doc.Id) + if err != nil { + return nil, fmt.Errorf("failed to get latest version: %w", err) + } + + nextVersion := 1 + if latestVersion != nil { + nextVersion = latestVersion.Version + 1 + } + + version := &domain.DocumentVersion{ + Id: uuid.New().String(), + DocumentId: doc.Id, + UserId: userId, + Version: nextVersion, + Name: doc.Name, + Content: doc.Content, + Config: doc.Config, + Description: description, + CreatedAt: time.Now(), + } + + if err := app.DocumentVersionPers.Create(version); err != nil { + return nil, fmt.Errorf("failed to create version: %w", err) + } + + // Clean up old versions if we exceed the limit + count, err := app.DocumentVersionPers.GetVersionCount(doc.Id) + if err == nil && count > MaxVersionsPerDocument { + _ = app.DocumentVersionPers.DeleteOldVersions(doc.Id, MaxVersionsPerDocument) + } + + return version, nil +} + +// CreateVersionOnUpdate should be called when a document is updated to create an automatic version +func (app *DocumentApp) CreateVersionOnUpdate(doc *domain.Document, userId string) { + logger := app.Logger.With().Str("component", "application.document.version_on_update").Logger() + // Create version silently - don't fail the update if versioning fails + _, err := app.createVersionFromDocument(doc, userId, "Auto-save") + if err != nil { + logger.Error().Err(err).Str("document_id", doc.Id).Msg("failed to create version on update") + } +} diff --git a/application/drawing/drawing.go b/application/drawing/drawing.go new file mode 100644 index 0000000..f60b510 --- /dev/null +++ b/application/drawing/drawing.go @@ -0,0 +1,317 @@ +package drawing + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/drawing/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type DrawingApp struct { + Config config.Config + Logger zerolog.Logger + DrawingPers domain.DrawingPers + PermissionPers domain.PermissionPers + SpacePers domain.SpacePers +} + +func NewDrawingApp(config config.Config, logger zerolog.Logger, drawingPers domain.DrawingPers, permissionPers domain.PermissionPers, spacePers domain.SpacePers) *DrawingApp { + return &DrawingApp{ + Config: config, + Logger: logger, + DrawingPers: drawingPers, + PermissionPers: permissionPers, + SpacePers: spacePers, + } +} + +func (app *DrawingApp) CreateDrawing(input dto.CreateDrawingInput) (*dto.CreateDrawingOutput, error) { + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + // Convert elements to JSONBArray + var elements domain.JSONBArray + if input.Elements != nil { + elementsJSON, err := json.Marshal(input.Elements) + if err != nil { + return nil, fmt.Errorf("failed to marshal elements: %w", err) + } + json.Unmarshal(elementsJSON, &elements) + } else { + elements = domain.JSONBArray{} + } + + // Convert appState to JSONB + var appState domain.JSONB + if input.AppState != nil { + appStateJSON, err := json.Marshal(input.AppState) + if err != nil { + return nil, fmt.Errorf("failed to marshal appState: %w", err) + } + json.Unmarshal(appStateJSON, &appState) + } else { + appState = domain.JSONB{} + } + + // Convert files to JSONB + var files domain.JSONB + if input.Files != nil { + filesJSON, err := json.Marshal(input.Files) + if err != nil { + return nil, fmt.Errorf("failed to marshal files: %w", err) + } + json.Unmarshal(filesJSON, &files) + } else { + files = domain.JSONB{} + } + + drawing := &domain.Drawing{ + Id: uuid.New().String(), + SpaceId: input.SpaceId, + DocumentId: input.DocumentId, + Name: input.Name, + Icon: input.Icon, + Elements: elements, + AppState: appState, + Files: files, + Thumbnail: input.Thumbnail, + CreatedBy: input.UserId, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.DrawingPers.Create(drawing); err != nil { + return nil, fmt.Errorf("failed to create drawing: %w", err) + } + + // Auto-create owner permission for the creator + // This ensures they retain access and can manage permissions even if their space role is downgraded + if err := app.PermissionPers.UpsertUser(domain.PermissionTypeDrawing, drawing.Id, input.UserId, domain.PermissionRoleOwner); err != nil { + // Log but don't fail - the drawing is already created + app.Logger.Warn().Err(err).Str("drawing_id", drawing.Id).Str("user_id", input.UserId).Msg("failed to create creator permission") + } + + return &dto.CreateDrawingOutput{ + Id: drawing.Id, + Name: drawing.Name, + CreatedAt: drawing.CreatedAt, + }, nil +} + +func (app *DrawingApp) ListDrawings(input dto.ListDrawingsInput) (*dto.ListDrawingsOutput, error) { + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + drawings, err := app.DrawingPers.GetBySpaceId(input.SpaceId) + if err != nil { + return nil, fmt.Errorf("failed to list drawings: %w", err) + } + + output := &dto.ListDrawingsOutput{ + Drawings: make([]dto.DrawingItem, len(drawings)), + } + + for i, d := range drawings { + output.Drawings[i] = dto.DrawingItem{ + Id: d.Id, + DocumentId: d.DocumentId, + Name: d.Name, + Icon: d.Icon, + Thumbnail: d.Thumbnail, + CreatedBy: d.User.Username, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } + } + + return output, nil +} + +func (app *DrawingApp) GetDrawing(input dto.GetDrawingInput) (*dto.GetDrawingOutput, error) { + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return nil, fmt.Errorf("drawing not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + // Convert JSONB to slices/maps + var elements []interface{} + if drawing.Elements != nil { + elementsJSON, _ := json.Marshal(drawing.Elements) + json.Unmarshal(elementsJSON, &elements) + } + + var appState map[string]interface{} + if drawing.AppState != nil { + appStateJSON, _ := json.Marshal(drawing.AppState) + json.Unmarshal(appStateJSON, &appState) + } + + var files map[string]interface{} + if drawing.Files != nil { + filesJSON, _ := json.Marshal(drawing.Files) + json.Unmarshal(filesJSON, &files) + } + + return &dto.GetDrawingOutput{ + Id: drawing.Id, + SpaceId: drawing.SpaceId, + DocumentId: drawing.DocumentId, + Name: drawing.Name, + Icon: drawing.Icon, + Elements: elements, + AppState: appState, + Files: files, + Thumbnail: drawing.Thumbnail, + CreatedBy: drawing.User.Username, + CreatedAt: drawing.CreatedAt, + UpdatedAt: drawing.UpdatedAt, + }, nil +} + +func (app *DrawingApp) UpdateDrawing(input dto.UpdateDrawingInput) error { + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return fmt.Errorf("drawing not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if input.Name != nil { + drawing.Name = *input.Name + } + + if input.Icon != nil { + drawing.Icon = *input.Icon + } + + if input.Elements != nil { + elementsJSON, err := json.Marshal(input.Elements) + if err != nil { + return fmt.Errorf("failed to marshal elements: %w", err) + } + var elements domain.JSONBArray + json.Unmarshal(elementsJSON, &elements) + drawing.Elements = elements + } + + if input.AppState != nil { + appStateJSON, err := json.Marshal(input.AppState) + if err != nil { + return fmt.Errorf("failed to marshal appState: %w", err) + } + var appState domain.JSONB + json.Unmarshal(appStateJSON, &appState) + drawing.AppState = appState + } + + if input.Files != nil { + filesJSON, err := json.Marshal(input.Files) + if err != nil { + return fmt.Errorf("failed to marshal files: %w", err) + } + var files domain.JSONB + json.Unmarshal(filesJSON, &files) + drawing.Files = files + } + + if input.Thumbnail != nil { + drawing.Thumbnail = *input.Thumbnail + } + + drawing.UpdatedAt = time.Now() + + if err := app.DrawingPers.Update(drawing); err != nil { + return fmt.Errorf("failed to update drawing: %w", err) + } + + return nil +} + +func (app *DrawingApp) MoveDrawing(input dto.MoveDrawingInput) (*dto.MoveDrawingOutput, error) { + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return nil, fmt.Errorf("drawing not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return nil, fmt.Errorf("access denied") + } + + drawing.DocumentId = input.DocumentId + drawing.UpdatedAt = time.Now() + + if err := app.DrawingPers.Update(drawing); err != nil { + return nil, fmt.Errorf("failed to move drawing: %w", err) + } + + return &dto.MoveDrawingOutput{ + Id: drawing.Id, + DocumentId: drawing.DocumentId, + }, nil +} + +func (app *DrawingApp) DeleteDrawing(input dto.DeleteDrawingInput) error { + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return fmt.Errorf("drawing not found: %w", err) + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.UserId) == nil { + return fmt.Errorf("access denied") + } + + if err := app.DrawingPers.Delete(input.DrawingId); err != nil { + return fmt.Errorf("failed to delete drawing: %w", err) + } + + return nil +} diff --git a/application/drawing/dto/drawing.go b/application/drawing/dto/drawing.go new file mode 100644 index 0000000..1d2e91a --- /dev/null +++ b/application/drawing/dto/drawing.go @@ -0,0 +1,94 @@ +package dto + +import "time" + +// Create drawing +type CreateDrawingInput struct { + UserId string + SpaceId string + DocumentId *string + Name string + Icon string + Elements []interface{} + AppState map[string]interface{} + Files map[string]interface{} + Thumbnail string +} + +type CreateDrawingOutput struct { + Id string + Name string + CreatedAt time.Time +} + +// List drawings +type ListDrawingsInput struct { + UserId string + SpaceId string +} + +type DrawingItem struct { + Id string + DocumentId *string + Name string + Icon string + Thumbnail string + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + +type ListDrawingsOutput struct { + Drawings []DrawingItem +} + +// Get drawing +type GetDrawingInput struct { + UserId string + DrawingId string +} + +type GetDrawingOutput struct { + Id string + SpaceId string + DocumentId *string + Name string + Icon string + Elements []interface{} + AppState map[string]interface{} + Files map[string]interface{} + Thumbnail string + CreatedBy string + CreatedAt time.Time + UpdatedAt time.Time +} + +// Update drawing +type UpdateDrawingInput struct { + UserId string + DrawingId string + Name *string + Icon *string + Elements []interface{} + AppState map[string]interface{} + Files map[string]interface{} + Thumbnail *string +} + +// Delete drawing +type DeleteDrawingInput struct { + UserId string + DrawingId string +} + +// Move drawing +type MoveDrawingInput struct { + UserId string + DrawingId string + DocumentId *string // nil = move to root (no parent document) +} + +type MoveDrawingOutput struct { + Id string + DocumentId *string +} diff --git a/application/drawing/dto/permissions.go b/application/drawing/dto/permissions.go new file mode 100644 index 0000000..c41650f --- /dev/null +++ b/application/drawing/dto/permissions.go @@ -0,0 +1,25 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type ListDrawingPermissionsInput struct { + RequesterId string + DrawingId string +} + +type ListDrawingPermissionsOutput struct { + Permissions []domain.Permission +} + +type UpsertDrawingUserPermissionInput struct { + RequesterId string + DrawingId string + TargetUserId string + Role domain.PermissionRole +} + +type DeleteDrawingUserPermissionInput struct { + RequesterId string + DrawingId string + TargetUserId string +} diff --git a/application/drawing/permissions.go b/application/drawing/permissions.go new file mode 100644 index 0000000..d472608 --- /dev/null +++ b/application/drawing/permissions.go @@ -0,0 +1,89 @@ +package drawing + +import ( + "fmt" + + "github.com/labbs/nexo/application/drawing/dto" + "github.com/labbs/nexo/domain" +) + +func (app *DrawingApp) ListDrawingPermissions(input dto.ListDrawingPermissionsInput) (*dto.ListDrawingPermissionsOutput, error) { + // Get the drawing + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return nil, fmt.Errorf("not_found") + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return nil, fmt.Errorf("space not found: %w", err) + } + + if space.GetUserRole(input.RequesterId) == nil { + return nil, fmt.Errorf("forbidden") + } + + permissions, err := app.PermissionPers.ListByResource(domain.PermissionTypeDrawing, input.DrawingId) + if err != nil { + return nil, err + } + + return &dto.ListDrawingPermissionsOutput{Permissions: permissions}, nil +} + +func (app *DrawingApp) UpsertDrawingUserPermission(input dto.UpsertDrawingUserPermissionInput) error { + // Get the drawing + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return fmt.Errorf("not_found") + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + // User must be admin/owner of space to manage permissions + role := space.GetUserRole(input.RequesterId) + if role == nil || (*role != domain.PermissionRoleOwner && *role != domain.PermissionRoleAdmin) { + return fmt.Errorf("forbidden") + } + + // Prevent changing the owner's role in personal/private spaces + if (space.Type == domain.SpaceTypePersonal || space.Type == domain.SpaceTypePrivate) && + space.OwnerId != nil && *space.OwnerId == input.TargetUserId && input.Role != domain.PermissionRoleOwner { + return fmt.Errorf("cannot_change_owner_role") + } + + return app.PermissionPers.UpsertUser(domain.PermissionTypeDrawing, input.DrawingId, input.TargetUserId, input.Role) +} + +func (app *DrawingApp) DeleteDrawingUserPermission(input dto.DeleteDrawingUserPermissionInput) error { + // Get the drawing + drawing, err := app.DrawingPers.GetById(input.DrawingId) + if err != nil { + return fmt.Errorf("not_found") + } + + // Verify user has access to the space + space, err := app.SpacePers.GetSpaceById(drawing.SpaceId) + if err != nil { + return fmt.Errorf("space not found: %w", err) + } + + // User must be admin/owner of space to manage permissions + role := space.GetUserRole(input.RequesterId) + if role == nil || (*role != domain.PermissionRoleOwner && *role != domain.PermissionRoleAdmin) { + return fmt.Errorf("forbidden") + } + + // Prevent removing the space owner in personal/private spaces + if (space.Type == domain.SpaceTypePersonal || space.Type == domain.SpaceTypePrivate) && + space.OwnerId != nil && *space.OwnerId == input.TargetUserId { + return fmt.Errorf("cannot_remove_owner") + } + + return app.PermissionPers.DeleteUser(domain.PermissionTypeDrawing, input.DrawingId, input.TargetUserId) +} diff --git a/application/favorite/add.go b/application/favorite/add.go new file mode 100644 index 0000000..7652eba --- /dev/null +++ b/application/favorite/add.go @@ -0,0 +1,63 @@ +package favorite + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/labbs/nexo/application/favorite/dto" + "github.com/labbs/nexo/domain" +) + +func (a *FavoriteApp) AddFavorite(input dto.AddFavoriteInput) (*dto.AddFavoriteOutput, error) { + logger := a.Logger.With().Str("component", "application.favorite.add_favorite").Logger() + + // Verify user has access to the document + doc, err := a.DocumentPers.GetDocumentByIdOrSlugWithUserPermissions(input.SpaceId, &input.DocumentId, nil, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get document") + return nil, fmt.Errorf("failed to get document: %w", err) + } + + if !doc.HasPermission(input.UserId, domain.PermissionRoleViewer) { + logger.Error().Msg("user does not have permission to view document") + return nil, fmt.Errorf("user does not have permission to view document") + } + + // Get the next position + latestPosition, err := a.FavoritePers.GetLatestFavoritePositionByUser(input.UserId) + if err != nil { + // If no favorites exist, start at 0 + latestPosition = -1 + } + + favorite := &domain.Favorite{ + Id: utils.UUIDv4(), + UserId: input.UserId, + DocumentId: input.DocumentId, + SpaceId: input.SpaceId, + Position: latestPosition + 1, + } + + err = a.FavoritePers.Create(favorite) + if err != nil { + logger.Error().Err(err).Msg("failed to create favorite") + return nil, fmt.Errorf("failed to create favorite: %w", err) + } + + return &dto.AddFavoriteOutput{ + Favorite: &dto.Favorite{ + Id: favorite.Id, + UserId: favorite.UserId, + DocumentId: favorite.DocumentId, + SpaceId: favorite.SpaceId, + Position: favorite.Position, + CreatedAt: favorite.CreatedAt, + Document: &dto.FavoriteDocument{ + Id: doc.Id, + Name: doc.Name, + Slug: doc.Slug, + Icon: doc.Config.Icon, + }, + }, + }, nil +} diff --git a/application/favorite/dto/add.go b/application/favorite/dto/add.go new file mode 100644 index 0000000..4359de4 --- /dev/null +++ b/application/favorite/dto/add.go @@ -0,0 +1,11 @@ +package dto + +type AddFavoriteInput struct { + UserId string + DocumentId string + SpaceId string +} + +type AddFavoriteOutput struct { + Favorite *Favorite +} diff --git a/application/favorite/dto/favorite.go b/application/favorite/dto/favorite.go new file mode 100644 index 0000000..81f30b3 --- /dev/null +++ b/application/favorite/dto/favorite.go @@ -0,0 +1,24 @@ +package dto + +import "time" + +// Favorite represents a favorite document for the application layer +type Favorite struct { + Id string + UserId string + DocumentId string + SpaceId string + Position int + CreatedAt time.Time + + // Document info for display + Document *FavoriteDocument +} + +// FavoriteDocument contains minimal document info for favorites list +type FavoriteDocument struct { + Id string + Name string + Slug string + Icon string +} diff --git a/application/favorite/dto/get.go b/application/favorite/dto/get.go new file mode 100644 index 0000000..86d381b --- /dev/null +++ b/application/favorite/dto/get.go @@ -0,0 +1,9 @@ +package dto + +type GetMyFavoritesInput struct { + UserId string +} + +type GetMyFavoritesOutput struct { + Favorites []Favorite +} diff --git a/application/favorite/dto/remove.go b/application/favorite/dto/remove.go new file mode 100644 index 0000000..a27cc55 --- /dev/null +++ b/application/favorite/dto/remove.go @@ -0,0 +1,10 @@ +package dto + +type RemoveFavoriteInput struct { + FavoriteId string + UserId string +} + +type RemoveFavoriteOutput struct { + Message string +} diff --git a/application/favorite/dto/update_position.go b/application/favorite/dto/update_position.go new file mode 100644 index 0000000..931d207 --- /dev/null +++ b/application/favorite/dto/update_position.go @@ -0,0 +1,11 @@ +package dto + +type UpdateFavoritePositionInput struct { + FavoriteId string + UserId string + Position int +} + +type UpdateFavoritePositionOutput struct { + Favorite *Favorite +} diff --git a/application/favorite/favorite.go b/application/favorite/favorite.go new file mode 100644 index 0000000..82e537f --- /dev/null +++ b/application/favorite/favorite.go @@ -0,0 +1,23 @@ +package favorite + +import ( + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type FavoriteApp struct { + Config config.Config + Logger zerolog.Logger + FavoritePers domain.FavoritePers + DocumentPers domain.DocumentPers +} + +func NewFavoriteApp(config config.Config, logger zerolog.Logger, favoritePers domain.FavoritePers, documentPers domain.DocumentPers) *FavoriteApp { + return &FavoriteApp{ + Config: config, + Logger: logger, + FavoritePers: favoritePers, + DocumentPers: documentPers, + } +} diff --git a/application/favorite/get.go b/application/favorite/get.go new file mode 100644 index 0000000..0e9edee --- /dev/null +++ b/application/favorite/get.go @@ -0,0 +1,43 @@ +package favorite + +import ( + "fmt" + + "github.com/labbs/nexo/application/favorite/dto" +) + +func (a *FavoriteApp) GetMyFavorites(input dto.GetMyFavoritesInput) (*dto.GetMyFavoritesOutput, error) { + logger := a.Logger.With().Str("component", "application.favorite.get_my_favorites").Logger() + + favorites, err := a.FavoritePers.GetMyFavoritesWithMainDocumentInformations(input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get favorites") + return nil, fmt.Errorf("failed to get favorites: %w", err) + } + + result := make([]dto.Favorite, len(favorites)) + for i, fav := range favorites { + result[i] = dto.Favorite{ + Id: fav.Id, + UserId: fav.UserId, + DocumentId: fav.DocumentId, + SpaceId: fav.SpaceId, + Position: fav.Position, + CreatedAt: fav.CreatedAt, + } + + // Add document info if available + if fav.Document.Id != "" { + result[i].Document = &dto.FavoriteDocument{ + Id: fav.Document.Id, + Name: fav.Document.Name, + Slug: fav.Document.Slug, + Icon: fav.Document.Config.Icon, + } + } + } + + return &dto.GetMyFavoritesOutput{ + Favorites: result, + }, nil +} diff --git a/application/favorite/remove.go b/application/favorite/remove.go new file mode 100644 index 0000000..55a320a --- /dev/null +++ b/application/favorite/remove.go @@ -0,0 +1,28 @@ +package favorite + +import ( + "fmt" + + "github.com/labbs/nexo/application/favorite/dto" +) + +func (a *FavoriteApp) RemoveFavorite(input dto.RemoveFavoriteInput) (*dto.RemoveFavoriteOutput, error) { + logger := a.Logger.With().Str("component", "application.favorite.remove_favorite").Logger() + + // Verify the favorite belongs to the user + favorite, err := a.FavoritePers.GetFavoriteByIdAndUserId(input.FavoriteId, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("favorite not found or does not belong to user") + return nil, fmt.Errorf("favorite not found: %w", err) + } + + err = a.FavoritePers.Delete(favorite.DocumentId, favorite.UserId, favorite.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to delete favorite") + return nil, fmt.Errorf("failed to delete favorite: %w", err) + } + + return &dto.RemoveFavoriteOutput{ + Message: "Favorite removed successfully", + }, nil +} diff --git a/application/favorite/update_position.go b/application/favorite/update_position.go new file mode 100644 index 0000000..3685fc6 --- /dev/null +++ b/application/favorite/update_position.go @@ -0,0 +1,38 @@ +package favorite + +import ( + "fmt" + + "github.com/labbs/nexo/application/favorite/dto" +) + +func (a *FavoriteApp) UpdateFavoritePosition(input dto.UpdateFavoritePositionInput) (*dto.UpdateFavoritePositionOutput, error) { + logger := a.Logger.With().Str("component", "application.favorite.update_position").Logger() + + // Verify the favorite belongs to the user + favorite, err := a.FavoritePers.GetFavoriteByIdAndUserId(input.FavoriteId, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("favorite not found or does not belong to user") + return nil, fmt.Errorf("favorite not found: %w", err) + } + + // Update the position + favorite.Position = input.Position + + err = a.FavoritePers.UpdateFavoritePosition(favorite) + if err != nil { + logger.Error().Err(err).Msg("failed to update favorite position") + return nil, fmt.Errorf("failed to update favorite position: %w", err) + } + + return &dto.UpdateFavoritePositionOutput{ + Favorite: &dto.Favorite{ + Id: favorite.Id, + UserId: favorite.UserId, + DocumentId: favorite.DocumentId, + SpaceId: favorite.SpaceId, + Position: favorite.Position, + CreatedAt: favorite.CreatedAt, + }, + }, nil +} diff --git a/application/group/group.go b/application/group/group.go new file mode 100644 index 0000000..4740433 --- /dev/null +++ b/application/group/group.go @@ -0,0 +1,130 @@ +package group + +import ( + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type GroupApp struct { + Config config.Config + Logger zerolog.Logger + GroupPers domain.GroupPers + UserPers domain.UserPers +} + +func NewGroupApp(config config.Config, logger zerolog.Logger, groupPers domain.GroupPers, userPers domain.UserPers) *GroupApp { + return &GroupApp{ + Config: config, + Logger: logger, + GroupPers: groupPers, + UserPers: userPers, + } +} + +// CreateGroup creates a new group +func (app *GroupApp) CreateGroup(name, description, ownerId string, role domain.Role) (*domain.Group, error) { + logger := app.Logger.With().Str("component", "application.group.create").Logger() + + group := &domain.Group{ + Name: name, + Description: description, + OwnerId: ownerId, + Role: role, + } + + if err := app.GroupPers.Create(group); err != nil { + logger.Error().Err(err).Msg("failed to create group") + return nil, err + } + + return group, nil +} + +// GetGroup retrieves a group by ID +func (app *GroupApp) GetGroup(groupId string) (*domain.Group, error) { + return app.GroupPers.GetById(groupId) +} + +// GetAllGroups retrieves all groups with pagination +func (app *GroupApp) GetAllGroups(limit, offset int) ([]domain.Group, int64, error) { + logger := app.Logger.With().Str("component", "application.group.get_all").Logger() + + groups, total, err := app.GroupPers.GetAll(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get all groups") + return nil, 0, err + } + + return groups, total, nil +} + +// UpdateGroup updates a group's name, description, or role +func (app *GroupApp) UpdateGroup(groupId, name, description string, role domain.Role) error { + logger := app.Logger.With().Str("component", "application.group.update").Logger() + + group, err := app.GroupPers.GetById(groupId) + if err != nil { + logger.Error().Err(err).Msg("failed to get group") + return err + } + + group.Name = name + group.Description = description + group.Role = role + + if err := app.GroupPers.Update(group); err != nil { + logger.Error().Err(err).Msg("failed to update group") + return err + } + + return nil +} + +// DeleteGroup deletes a group +func (app *GroupApp) DeleteGroup(groupId string) error { + logger := app.Logger.With().Str("component", "application.group.delete").Logger() + + if err := app.GroupPers.Delete(groupId); err != nil { + logger.Error().Err(err).Msg("failed to delete group") + return err + } + + return nil +} + +// AddMember adds a user to a group +func (app *GroupApp) AddMember(groupId, userId string) error { + logger := app.Logger.With().Str("component", "application.group.add_member").Logger() + + // Verify user exists + _, err := app.UserPers.GetById(userId) + if err != nil { + logger.Error().Err(err).Str("user_id", userId).Msg("user not found") + return err + } + + if err := app.GroupPers.AddMember(groupId, userId); err != nil { + logger.Error().Err(err).Msg("failed to add member to group") + return err + } + + return nil +} + +// RemoveMember removes a user from a group +func (app *GroupApp) RemoveMember(groupId, userId string) error { + logger := app.Logger.With().Str("component", "application.group.remove_member").Logger() + + if err := app.GroupPers.RemoveMember(groupId, userId); err != nil { + logger.Error().Err(err).Msg("failed to remove member from group") + return err + } + + return nil +} + +// GetMembers retrieves all members of a group +func (app *GroupApp) GetMembers(groupId string) ([]domain.User, error) { + return app.GroupPers.GetMembers(groupId) +} diff --git a/application/ports/document.go b/application/ports/document.go new file mode 100644 index 0000000..24f3dd4 --- /dev/null +++ b/application/ports/document.go @@ -0,0 +1,14 @@ +package ports + +import ( + "github.com/labbs/nexo/application/document/dto" +) + +type DocumentPort interface { + CreateDocument(input dto.CreateDocumentInput) (*dto.CreateDocumentOutput, error) + GetDocumentWithSpace(input dto.GetDocumentWithSpaceInput) (*dto.GetDocumentWithSpaceOutput, error) + GetDocumentsFromSpaceWithUserPermissions(input dto.GetDocumentsFromSpaceInput) (*dto.GetDocumentsFromSpaceOutput, error) + UpdateDocument(input dto.UpdateDocumentInput) (*dto.UpdateDocumentOutput, error) + MoveDocument(input dto.MoveDocumentInput) (*dto.MoveDocumentOutput, error) + DeleteDocument(input dto.DeleteDocumentInput) error +} diff --git a/application/ports/session.go b/application/ports/session.go new file mode 100644 index 0000000..94f5775 --- /dev/null +++ b/application/ports/session.go @@ -0,0 +1,16 @@ +package ports + +import ( + "github.com/labbs/nexo/application/session/dto" +) + +type SessionPort interface { + Create(input dto.CreateSessionInput) (*dto.CreateSessionOutput, error) + DeleteExpired() error + ValidateToken(input dto.ValidateTokenInput) (*dto.ValidateTokenOutput, error) + HasRole(input dto.HasRoleInput) bool + HasScope(input dto.HasScopeInput) bool + CanAccessResource(input dto.CanAccessResourceInput) (bool, error) + GetUserPermissions(input dto.GetUserPermissionsInput) (*dto.GetUserPermissionsOutput, error) + InvalidateSession(input dto.InvalidateSessionInput) error +} diff --git a/application/ports/space.go b/application/ports/space.go new file mode 100644 index 0000000..ac87ebc --- /dev/null +++ b/application/ports/space.go @@ -0,0 +1,16 @@ +package ports + +import ( + "github.com/labbs/nexo/application/space/dto" +) + +type SpacePort interface { + CreatePrivateSpaceForUser(input dto.CreatePrivateSpaceForUserInput) (*dto.CreatePrivateSpaceForUserOutput, error) + CreateSpace(input dto.CreateSpaceInput) (*dto.CreateSpaceOutput, error) + GetSpacesForUser(input dto.GetSpacesForUserInput) (*dto.GetSpacesForUserOutput, error) + UpdateSpace(input dto.UpdateSpaceInput) (*dto.UpdateSpaceOutput, error) + DeleteSpace(input dto.DeleteSpaceInput) error + ListSpacePermissions(input dto.ListSpacePermissionsInput) (*dto.ListSpacePermissionsOutput, error) + UpsertSpaceUserPermission(input dto.UpsertSpaceUserPermissionInput) error + DeleteSpaceUserPermission(input dto.DeleteSpaceUserPermissionInput) error +} diff --git a/application/ports/user.go b/application/ports/user.go new file mode 100644 index 0000000..2cfa0f3 --- /dev/null +++ b/application/ports/user.go @@ -0,0 +1,16 @@ +package ports + +import ( + "github.com/labbs/nexo/application/user/dto" +) + +type UserPort interface { + Create(input dto.CreateUserInput) (*dto.CreateUserOutput, error) + GetByEmail(input dto.GetByEmailInput) (*dto.GetByEmailOutput, error) + GetByUserId(input dto.GetByUserIdInput) (*dto.GetByUserIdOutput, error) + CreateFavorite(input dto.CreateFavoriteInput) error + DeleteFavorite(input dto.DeleteFavoriteInput) error + GetMyFavorites(input dto.GetMyFavoritesInput) (*dto.GetMyFavoritesOutput, error) + GetFavoriteByIdAndUserId(input dto.GetFavoriteByIdAndUserIdInput) (*dto.GetFavoriteByIdAndUserIdOutput, error) + UpdateFavoritePosition(input dto.UpdateFavoritePositionInput) (*dto.UpdateFavoritePositionOutput, error) +} diff --git a/application/session/create.go b/application/session/create.go new file mode 100644 index 0000000..19e2d40 --- /dev/null +++ b/application/session/create.go @@ -0,0 +1,33 @@ +package session + +import ( + "github.com/gofiber/fiber/v2/utils" + "github.com/labbs/nexo/application/session/dto" + "github.com/labbs/nexo/domain" +) + +func (c *SessionApp) Create(input dto.CreateSessionInput) (*dto.CreateSessionOutput, error) { + logger := c.Logger.With().Str("component", "application.session.create").Logger() + + session := &domain.Session{ + Id: utils.UUIDv4(), + UserId: input.UserId, + UserAgent: input.UserAgent, + IpAddress: input.IpAddress, + ExpiresAt: input.ExpiresAt, + } + + err := c.SessionPers.Create(session) + if err != nil { + logger.Error().Err(err).Str("session_id", session.Id).Str("user_id", session.UserId).Msg("failed to create session") + return nil, err + } + + return &dto.CreateSessionOutput{SessionId: session.Id}, nil +} + +func (c *SessionApp) DeleteExpired() error { + // logger := c.Logger.With().Str("component", "application.session.delete_expired").Logger() + + return nil +} diff --git a/application/session/dto/create.go b/application/session/dto/create.go new file mode 100644 index 0000000..9ae4948 --- /dev/null +++ b/application/session/dto/create.go @@ -0,0 +1,14 @@ +package dto + +import "time" + +type CreateSessionInput struct { + UserId string + UserAgent string + IpAddress string + ExpiresAt time.Time +} + +type CreateSessionOutput struct { + SessionId string +} diff --git a/application/session/dto/invalidate.go b/application/session/dto/invalidate.go new file mode 100644 index 0000000..ea90f79 --- /dev/null +++ b/application/session/dto/invalidate.go @@ -0,0 +1,5 @@ +package dto + +type InvalidateSessionInput struct { + SessionId string +} diff --git a/application/session/dto/validate.go b/application/session/dto/validate.go new file mode 100644 index 0000000..3767b92 --- /dev/null +++ b/application/session/dto/validate.go @@ -0,0 +1,38 @@ +package dto + +import fiberoapi "github.com/labbs/fiber-oapi" + +type ValidateTokenInput struct { + Token string +} + +type ValidateTokenOutput struct { + AuthContext *fiberoapi.AuthContext +} + +type HasRoleInput struct { + Context *fiberoapi.AuthContext + Role string +} + +type HasScopeInput struct { + Context *fiberoapi.AuthContext + Scope string +} + +type CanAccessResourceInput struct { + Context *fiberoapi.AuthContext + ResourceType string + ResourceID string + Action string +} + +type GetUserPermissionsInput struct { + Context *fiberoapi.AuthContext + ResourceType string + ResourceID string +} + +type GetUserPermissionsOutput struct { + Permission *fiberoapi.ResourcePermission +} diff --git a/application/session/invalidate.go b/application/session/invalidate.go new file mode 100644 index 0000000..1e873e8 --- /dev/null +++ b/application/session/invalidate.go @@ -0,0 +1,15 @@ +package session + +import "github.com/labbs/nexo/application/session/dto" + +func (c *SessionApp) InvalidateSession(input dto.InvalidateSessionInput) error { + logger := c.Logger.With().Str("component", "application.session.invalidate_session").Logger() + + err := c.SessionPers.DeleteById(input.SessionId) + if err != nil { + logger.Error().Err(err).Str("session_id", input.SessionId).Msg("failed to invalidate session") + return err + } + + return nil +} diff --git a/application/session/session.go b/application/session/session.go new file mode 100644 index 0000000..ad02851 --- /dev/null +++ b/application/session/session.go @@ -0,0 +1,24 @@ +package session + +import ( + "github.com/labbs/nexo/application/ports" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type SessionApp struct { + Config config.Config + Logger zerolog.Logger + SessionPers domain.SessionPers + UserApp ports.UserPort +} + +func NewSessionApp(config config.Config, logger zerolog.Logger, sessionPers domain.SessionPers, userApp ports.UserPort) *SessionApp { + return &SessionApp{ + Config: config, + Logger: logger, + SessionPers: sessionPers, + UserApp: userApp, + } +} diff --git a/application/session/validate.go b/application/session/validate.go new file mode 100644 index 0000000..0a7da0b --- /dev/null +++ b/application/session/validate.go @@ -0,0 +1,72 @@ +package session + +import ( + "fmt" + "time" + + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/session/dto" + "github.com/labbs/nexo/infrastructure/helpers/tokenutil" +) + +func (c *SessionApp) ValidateToken(input dto.ValidateTokenInput) (*dto.ValidateTokenOutput, error) { + logger := c.Logger.With().Str("component", "application.session.validate_token").Logger() + + sessionId, err := tokenutil.GetSessionIdFromToken(input.Token, c.Config) + if err != nil { + logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session id from token") + return nil, fmt.Errorf("invalid token") + } + + session, err := c.SessionPers.GetById(sessionId) + if err != nil { + logger.Error().Err(err).Str("token", input.Token).Msg("failed to get session by token") + return nil, fmt.Errorf("invalid token") + } + + if session.ExpiresAt.Before(time.Now()) { + logger.Warn().Str("token", input.Token).Msg("session has expired") + return nil, fmt.Errorf("session has expired") + } + + ctx := &fiberoapi.AuthContext{ + UserID: session.UserId, + Claims: map[string]interface{}{ + "session_id": session.Id, + }, + } + + return &dto.ValidateTokenOutput{AuthContext: ctx}, nil +} + +func (c *SessionApp) HasRole(input dto.HasRoleInput) bool { + logger := c.Logger.With().Str("component", "application.session.has_role").Logger() + + logger.Warn().Msg("not implemented") + + return false +} + +func (c *SessionApp) HasScope(input dto.HasScopeInput) bool { + logger := c.Logger.With().Str("component", "application.session.has_scope").Logger() + + logger.Warn().Msg("not implemented") + + return false +} + +func (c *SessionApp) CanAccessResource(input dto.CanAccessResourceInput) (bool, error) { + logger := c.Logger.With().Str("component", "application.session.can_access_resource").Logger() + + logger.Warn().Msg("not implemented") + + return false, fmt.Errorf("not implemented") +} + +func (c *SessionApp) GetUserPermissions(input dto.GetUserPermissionsInput) (*dto.GetUserPermissionsOutput, error) { + logger := c.Logger.With().Str("component", "application.session.get_user_permissions").Logger() + + logger.Warn().Msg("not implemented") + + return nil, fmt.Errorf("not implemented") +} diff --git a/application/space/admin.go b/application/space/admin.go new file mode 100644 index 0000000..4401412 --- /dev/null +++ b/application/space/admin.go @@ -0,0 +1,148 @@ +package space + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/gosimple/slug" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/shortuuid" +) + +// GetAllSpaces returns all spaces with pagination (admin only) +func (c *SpaceApp) GetAllSpaces(limit, offset int) ([]domain.Space, int64, error) { + logger := c.Logger.With().Str("component", "application.space.get_all_spaces").Logger() + + spaces, total, err := c.SpacePres.GetAll(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get all spaces") + return nil, 0, err + } + + return spaces, total, nil +} + +// AdminCreateSpace creates a space without requiring an owner (admin only) +func (c *SpaceApp) AdminCreateSpace(name, icon, iconColor string, spaceType domain.SpaceType, ownerId *string) (*domain.Space, error) { + logger := c.Logger.With().Str("component", "application.space.admin_create_space").Logger() + + space := &domain.Space{ + Id: utils.UUIDv4(), + Slug: slug.Make(name + "-" + shortuuid.GenerateShortUUID()), + Name: name, + Type: spaceType, + OwnerId: ownerId, + Icon: icon, + IconColor: iconColor, + } + + err := c.SpacePres.Create(space) + if err != nil { + logger.Error().Err(err).Str("name", name).Msg("failed to create space") + return nil, fmt.Errorf("failed to create space: %w", err) + } + + return space, nil +} + +// AdminUpdateSpace updates a space (admin only) +func (c *SpaceApp) AdminUpdateSpace(spaceId, name, icon, iconColor string, spaceType domain.SpaceType, ownerId *string) error { + logger := c.Logger.With().Str("component", "application.space.admin_update_space").Logger() + + space, err := c.SpacePres.GetSpaceById(spaceId) + if err != nil { + return fmt.Errorf("space not found") + } + + space.Name = name + space.Icon = icon + space.IconColor = iconColor + space.Type = spaceType + space.OwnerId = ownerId + + err = c.SpacePres.Update(space) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Msg("failed to update space") + return fmt.Errorf("failed to update space: %w", err) + } + + return nil +} + +// AdminDeleteSpace deletes a space (admin only) +func (c *SpaceApp) AdminDeleteSpace(spaceId string) error { + logger := c.Logger.With().Str("component", "application.space.admin_delete_space").Logger() + + err := c.SpacePres.Delete(spaceId) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Msg("failed to delete space") + return fmt.Errorf("failed to delete space: %w", err) + } + + return nil +} + +// AdminListSpacePermissions lists all permissions for a space with user/group details (admin only) +func (c *SpaceApp) AdminListSpacePermissions(spaceId string) ([]domain.Permission, error) { + logger := c.Logger.With().Str("component", "application.space.admin_list_permissions").Logger() + + permissions, err := c.PermissionPers.ListByResource(domain.PermissionTypeSpace, spaceId) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Msg("failed to list permissions") + return nil, fmt.Errorf("failed to list permissions: %w", err) + } + + return permissions, nil +} + +// AdminAddSpaceUserPermission adds a user permission to a space (admin only) +func (c *SpaceApp) AdminAddSpaceUserPermission(spaceId, userId string, role domain.PermissionRole) error { + logger := c.Logger.With().Str("component", "application.space.admin_add_user_permission").Logger() + + err := c.PermissionPers.UpsertUser(domain.PermissionTypeSpace, spaceId, userId, role) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Str("userId", userId).Msg("failed to add user permission") + return fmt.Errorf("failed to add user permission: %w", err) + } + + return nil +} + +// AdminRemoveSpaceUserPermission removes a user permission from a space (admin only) +func (c *SpaceApp) AdminRemoveSpaceUserPermission(spaceId, userId string) error { + logger := c.Logger.With().Str("component", "application.space.admin_remove_user_permission").Logger() + + err := c.PermissionPers.DeleteUser(domain.PermissionTypeSpace, spaceId, userId) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Str("userId", userId).Msg("failed to remove user permission") + return fmt.Errorf("failed to remove user permission: %w", err) + } + + return nil +} + +// AdminAddSpaceGroupPermission adds a group permission to a space (admin only) +func (c *SpaceApp) AdminAddSpaceGroupPermission(spaceId, groupId string, role domain.PermissionRole) error { + logger := c.Logger.With().Str("component", "application.space.admin_add_group_permission").Logger() + + err := c.PermissionPers.UpsertGroup(domain.PermissionTypeSpace, spaceId, groupId, role) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Str("groupId", groupId).Msg("failed to add group permission") + return fmt.Errorf("failed to add group permission: %w", err) + } + + return nil +} + +// AdminRemoveSpaceGroupPermission removes a group permission from a space (admin only) +func (c *SpaceApp) AdminRemoveSpaceGroupPermission(spaceId, groupId string) error { + logger := c.Logger.With().Str("component", "application.space.admin_remove_group_permission").Logger() + + err := c.PermissionPers.DeleteGroup(domain.PermissionTypeSpace, spaceId, groupId) + if err != nil { + logger.Error().Err(err).Str("spaceId", spaceId).Str("groupId", groupId).Msg("failed to remove group permission") + return fmt.Errorf("failed to remove group permission: %w", err) + } + + return nil +} diff --git a/application/space/create.go b/application/space/create.go new file mode 100644 index 0000000..1d5fa28 --- /dev/null +++ b/application/space/create.go @@ -0,0 +1,67 @@ +package space + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/gosimple/slug" + "github.com/labbs/nexo/application/space/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/shortuuid" +) + +func (c *SpaceApp) CreatePrivateSpaceForUser(input dto.CreatePrivateSpaceForUserInput) (*dto.CreatePrivateSpaceForUserOutput, error) { + logger := c.Logger.With().Str("component", "application.space.createPrivateSpaceForUser").Logger() + + name := "Personal Space" + + space := &domain.Space{ + Id: utils.UUIDv4(), + Slug: slug.Make(name + "-" + shortuuid.GenerateShortUUID()), + Name: name, + Type: domain.SpaceTypePersonal, + OwnerId: &input.UserId, + } + + err := c.SpacePres.Create(space) + if err != nil { + logger.Error().Err(err).Str("userId", input.UserId).Msg("failed to create private space for user") + return nil, fmt.Errorf("failed to create private space for user: %w", err) + } + + // Auto-create owner permission for the creator + if err := c.PermissionPers.UpsertUser(domain.PermissionTypeSpace, space.Id, input.UserId, domain.PermissionRoleOwner); err != nil { + logger.Warn().Err(err).Str("space_id", space.Id).Str("user_id", input.UserId).Msg("failed to create owner permission") + } + + return &dto.CreatePrivateSpaceForUserOutput{Space: space}, nil +} + +func (c *SpaceApp) CreateSpace(input dto.CreateSpaceInput) (*dto.CreateSpaceOutput, error) { + logger := c.Logger.With().Str("component", "application.space.createSpace").Logger() + + space := &domain.Space{ + Id: utils.UUIDv4(), + Slug: slug.Make(input.Name + "-" + shortuuid.GenerateShortUUID()), + Name: input.Name, + Type: input.Type, + OwnerId: input.OwnerId, + Icon: *input.Icon, + IconColor: *input.IconColor, + } + + err := c.SpacePres.Create(space) + if err != nil { + logger.Error().Err(err).Str("name", input.Name).Msg("failed to create space") + return nil, fmt.Errorf("failed to create space: %w", err) + } + + // Auto-create owner permission for the creator + if input.OwnerId != nil { + if err := c.PermissionPers.UpsertUser(domain.PermissionTypeSpace, space.Id, *input.OwnerId, domain.PermissionRoleOwner); err != nil { + logger.Warn().Err(err).Str("space_id", space.Id).Str("user_id", *input.OwnerId).Msg("failed to create owner permission") + } + } + + return &dto.CreateSpaceOutput{Space: space}, nil +} diff --git a/application/space/delete.go b/application/space/delete.go new file mode 100644 index 0000000..cb3238f --- /dev/null +++ b/application/space/delete.go @@ -0,0 +1,35 @@ +package space + +import ( + "fmt" + + "github.com/labbs/nexo/application/space/dto" + "github.com/labbs/nexo/domain" +) + +func (c *SpaceApp) DeleteSpace(input dto.DeleteSpaceInput) error { + logger := c.Logger.With().Str("component", "application.space.delete_space").Logger() + + space, err := c.SpacePres.GetSpaceById(input.SpaceId) + if err != nil || space == nil { + return fmt.Errorf("not_found") + } + + // Only owner can delete (MVP policy) + if !space.HasPermission(input.UserId, domain.PermissionRoleOwner) { + return fmt.Errorf("forbidden") + } + + // Guard: forbid delete if there are active documents in space (MVP: check root docs) + docs, derr := c.DocumentPers.GetRootDocumentsFromSpaceWithUserPermissions(input.SpaceId, input.UserId) + if derr == nil && len(docs) > 0 { + return fmt.Errorf("conflict_children") + } + + if err := c.SpacePres.Delete(input.SpaceId); err != nil { + logger.Error().Err(err).Msg("failed to delete space") + return err + } + + return nil +} diff --git a/application/space/dto/create.go b/application/space/dto/create.go new file mode 100644 index 0000000..8a859f5 --- /dev/null +++ b/application/space/dto/create.go @@ -0,0 +1,23 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type CreatePrivateSpaceForUserInput struct { + UserId string +} + +type CreatePrivateSpaceForUserOutput struct { + Space *domain.Space +} + +type CreateSpaceInput struct { + Name string + Icon *string + IconColor *string + OwnerId *string + Type domain.SpaceType +} + +type CreateSpaceOutput struct { + Space *domain.Space +} diff --git a/application/space/dto/delete.go b/application/space/dto/delete.go new file mode 100644 index 0000000..02a621b --- /dev/null +++ b/application/space/dto/delete.go @@ -0,0 +1,6 @@ +package dto + +type DeleteSpaceInput struct { + UserId string + SpaceId string +} diff --git a/application/space/dto/get.go b/application/space/dto/get.go new file mode 100644 index 0000000..36d30e8 --- /dev/null +++ b/application/space/dto/get.go @@ -0,0 +1,11 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type GetSpacesForUserInput struct { + UserId string +} + +type GetSpacesForUserOutput struct { + Spaces []domain.Space +} diff --git a/application/space/dto/permissions.go b/application/space/dto/permissions.go new file mode 100644 index 0000000..3fe0416 --- /dev/null +++ b/application/space/dto/permissions.go @@ -0,0 +1,25 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type ListSpacePermissionsInput struct { + UserId string + SpaceId string +} + +type ListSpacePermissionsOutput struct { + Permissions []domain.Permission +} + +type UpsertSpaceUserPermissionInput struct { + RequesterId string + SpaceId string + TargetUserId string + Role domain.PermissionRole +} + +type DeleteSpaceUserPermissionInput struct { + RequesterId string + SpaceId string + TargetUserId string +} diff --git a/application/space/dto/update.go b/application/space/dto/update.go new file mode 100644 index 0000000..30fe813 --- /dev/null +++ b/application/space/dto/update.go @@ -0,0 +1,15 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type UpdateSpaceInput struct { + UserId string + SpaceId string + Name *string + Icon *string + IconColor *string +} + +type UpdateSpaceOutput struct { + Space *domain.Space +} diff --git a/application/space/get.go b/application/space/get.go new file mode 100644 index 0000000..f8551f8 --- /dev/null +++ b/application/space/get.go @@ -0,0 +1,19 @@ +package space + +import ( + "fmt" + + "github.com/labbs/nexo/application/space/dto" +) + +func (c *SpaceApp) GetSpacesForUser(input dto.GetSpacesForUserInput) (*dto.GetSpacesForUserOutput, error) { + logger := c.Logger.With().Str("component", "application.space.getSpacesForUser").Logger() + + spaces, err := c.SpacePres.GetSpacesForUser(input.UserId) + if err != nil { + logger.Error().Err(err).Str("userId", input.UserId).Msg("failed to get spaces for user") + return nil, fmt.Errorf("failed to get spaces for user: %w", err) + } + + return &dto.GetSpacesForUserOutput{Spaces: spaces}, nil +} diff --git a/application/space/permissions.go b/application/space/permissions.go new file mode 100644 index 0000000..ca645cc --- /dev/null +++ b/application/space/permissions.go @@ -0,0 +1,83 @@ +package space + +import ( + "fmt" + + "github.com/labbs/nexo/application/space/dto" + "github.com/labbs/nexo/domain" +) + +// Permissions (MVP: user-level only) +func (c *SpaceApp) ListSpacePermissions(input dto.ListSpacePermissionsInput) (*dto.ListSpacePermissionsOutput, error) { + space, err := c.SpacePres.GetSpaceById(input.SpaceId) + if err != nil || space == nil { + return nil, fmt.Errorf("not_found") + } + if !space.HasPermission(input.UserId, domain.PermissionRoleAdmin) { + return nil, fmt.Errorf("forbidden") + } + permissions, err := c.PermissionPers.ListByResource(domain.PermissionTypeSpace, input.SpaceId) + if err != nil { + return nil, err + } + + // Include the space owner if not already in the permissions list + if space.OwnerId != nil { + ownerFound := false + for _, p := range permissions { + if p.UserId != nil && *p.UserId == *space.OwnerId { + ownerFound = true + break + } + } + if !ownerFound { + ownerPerm := domain.Permission{ + Id: "owner-" + space.Id, + Type: domain.PermissionTypeSpace, + SpaceId: &space.Id, + UserId: space.OwnerId, + Role: domain.PermissionRoleOwner, + User: space.Owner, + } + permissions = append([]domain.Permission{ownerPerm}, permissions...) + } + } + + return &dto.ListSpacePermissionsOutput{Permissions: permissions}, nil +} + +func (c *SpaceApp) UpsertSpaceUserPermission(input dto.UpsertSpaceUserPermissionInput) error { + space, err := c.SpacePres.GetSpaceById(input.SpaceId) + if err != nil || space == nil { + return fmt.Errorf("not_found") + } + if !space.HasPermission(input.RequesterId, domain.PermissionRoleAdmin) { + return fmt.Errorf("forbidden") + } + + // Prevent changing the owner's role on personal/private spaces + if (space.Type == domain.SpaceTypePersonal || space.Type == domain.SpaceTypePrivate) && + space.OwnerId != nil && *space.OwnerId == input.TargetUserId && input.Role != domain.PermissionRoleOwner { + return fmt.Errorf("cannot_change_owner_role") + } + + return c.PermissionPers.UpsertUser(domain.PermissionTypeSpace, input.SpaceId, input.TargetUserId, input.Role) +} + +func (c *SpaceApp) DeleteSpaceUserPermission(input dto.DeleteSpaceUserPermissionInput) error { + space, err := c.SpacePres.GetSpaceById(input.SpaceId) + if err != nil || space == nil { + return fmt.Errorf("not_found") + } + if !space.HasPermission(input.RequesterId, domain.PermissionRoleAdmin) { + return fmt.Errorf("forbidden") + } + + // Prevent removing the owner from personal/private spaces + if (space.Type == domain.SpaceTypePersonal || space.Type == domain.SpaceTypePrivate) && + space.OwnerId != nil && *space.OwnerId == input.TargetUserId { + return fmt.Errorf("cannot_remove_owner") + } + + return c.PermissionPers.DeleteUser(domain.PermissionTypeSpace, input.SpaceId, input.TargetUserId) +} diff --git a/application/space/space.go b/application/space/space.go new file mode 100644 index 0000000..e5f8008 --- /dev/null +++ b/application/space/space.go @@ -0,0 +1,25 @@ +package space + +import ( + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type SpaceApp struct { + Config config.Config + Logger zerolog.Logger + SpacePres domain.SpacePers + DocumentPers domain.DocumentPers + PermissionPers domain.PermissionPers +} + +func NewSpaceApp(config config.Config, logger zerolog.Logger, spacePers domain.SpacePers, documentPers domain.DocumentPers, permissionPers domain.PermissionPers) *SpaceApp { + return &SpaceApp{ + Config: config, + Logger: logger, + SpacePres: spacePers, + DocumentPers: documentPers, + PermissionPers: permissionPers, + } +} diff --git a/application/space/update.go b/application/space/update.go new file mode 100644 index 0000000..db4bcfa --- /dev/null +++ b/application/space/update.go @@ -0,0 +1,43 @@ +package space + +import ( + "fmt" + + "github.com/gosimple/slug" + "github.com/labbs/nexo/application/space/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/shortuuid" +) + +func (c *SpaceApp) UpdateSpace(input dto.UpdateSpaceInput) (*dto.UpdateSpaceOutput, error) { + logger := c.Logger.With().Str("component", "application.space.update_space").Logger() + + space, err := c.SpacePres.GetSpaceById(input.SpaceId) + if err != nil || space == nil { + return nil, fmt.Errorf("not_found") + } + + // Require admin to update + if !space.HasPermission(input.UserId, domain.PermissionRoleAdmin) { + return nil, fmt.Errorf("forbidden") + } + + // Apply updates + if input.Name != nil && *input.Name != "" && space.Name != *input.Name { + space.Name = *input.Name + space.Slug = slug.Make(space.Name + "-" + shortuuid.GenerateShortUUID()) + } + if input.Icon != nil { + space.Icon = *input.Icon + } + if input.IconColor != nil { + space.IconColor = *input.IconColor + } + + if err := c.SpacePres.Update(space); err != nil { + logger.Error().Err(err).Msg("failed to update space") + return nil, err + } + + return &dto.UpdateSpaceOutput{Space: space}, nil +} diff --git a/application/user/admin.go b/application/user/admin.go new file mode 100644 index 0000000..b30ce15 --- /dev/null +++ b/application/user/admin.go @@ -0,0 +1,57 @@ +package user + +import ( + "github.com/labbs/nexo/domain" +) + +// GetAllUsers returns all users with pagination (admin only) +func (c *UserApp) GetAllUsers(limit, offset int) ([]domain.User, int64, error) { + logger := c.Logger.With().Str("component", "application.user.get_all_users").Logger() + + users, total, err := c.UserPres.GetAll(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get all users") + return nil, 0, err + } + + return users, total, nil +} + +// UpdateRole updates a user's role (admin only) +func (c *UserApp) UpdateRole(userId string, role domain.Role) error { + logger := c.Logger.With().Str("component", "application.user.update_role").Logger() + + err := c.UserPres.UpdateRole(userId, role) + if err != nil { + logger.Error().Err(err).Str("user_id", userId).Str("role", string(role)).Msg("failed to update user role") + return err + } + + return nil +} + +// UpdateActive updates a user's active status (admin only) +func (c *UserApp) UpdateActive(userId string, active bool) error { + logger := c.Logger.With().Str("component", "application.user.update_active").Logger() + + err := c.UserPres.UpdateActive(userId, active) + if err != nil { + logger.Error().Err(err).Str("user_id", userId).Bool("active", active).Msg("failed to update user active status") + return err + } + + return nil +} + +// DeleteUser deletes a user (admin only) +func (c *UserApp) DeleteUser(userId string) error { + logger := c.Logger.With().Str("component", "application.user.delete_user").Logger() + + err := c.UserPres.Delete(userId) + if err != nil { + logger.Error().Err(err).Str("user_id", userId).Msg("failed to delete user") + return err + } + + return nil +} diff --git a/application/user/create.go b/application/user/create.go new file mode 100644 index 0000000..38b76dc --- /dev/null +++ b/application/user/create.go @@ -0,0 +1,28 @@ +package user + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/labbs/nexo/application/user/dto" + helperError "github.com/labbs/nexo/infrastructure/helpers/error" + "gorm.io/gorm" +) + +func (c *UserApp) Create(input dto.CreateUserInput) (*dto.CreateUserOutput, error) { + logger := c.Logger.With().Str("component", "application.user.create").Logger() + + // Generate UUID for user + input.User.Id = utils.UUIDv4() + + createdUser, err := c.UserPres.Create(input.User) + if helperError.Catch(err) == gorm.ErrDuplicatedKey { + logger.Warn().Str("email", input.User.Email).Msg("user with the same email already exists") + return nil, fmt.Errorf("user with the same email already exists") + } else if err != nil { + logger.Error().Err(err).Str("email", input.User.Email).Msg("failed to create user") + return nil, err + } + + return &dto.CreateUserOutput{User: &createdUser}, nil +} diff --git a/application/user/dto/create.go b/application/user/dto/create.go new file mode 100644 index 0000000..054a50f --- /dev/null +++ b/application/user/dto/create.go @@ -0,0 +1,11 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type CreateUserInput struct { + User domain.User +} + +type CreateUserOutput struct { + User *domain.User +} diff --git a/application/user/dto/favorites.go b/application/user/dto/favorites.go new file mode 100644 index 0000000..a58d307 --- /dev/null +++ b/application/user/dto/favorites.go @@ -0,0 +1,42 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type CreateFavoriteInput struct { + DocumentId string + SpaceId string + UserId string +} + +type DeleteFavoriteInput struct { + DocumentId string + SpaceId string + UserId string +} + +type GetMyFavoritesInput struct { + UserId string +} + +type GetMyFavoritesOutput struct { + Favorites []domain.Favorite +} + +type GetFavoriteByIdAndUserIdInput struct { + FavoriteId string + UserId string +} + +type GetFavoriteByIdAndUserIdOutput struct { + Favorite *domain.Favorite +} + +type UpdateFavoritePositionInput struct { + UserId string + FavoriteId string + NewPosition int +} + +type UpdateFavoritePositionOutput struct { + Favorite *domain.Favorite +} diff --git a/application/user/dto/get.go b/application/user/dto/get.go new file mode 100644 index 0000000..82419af --- /dev/null +++ b/application/user/dto/get.go @@ -0,0 +1,19 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type GetByEmailInput struct { + Email string +} + +type GetByEmailOutput struct { + User *domain.User +} + +type GetByUserIdInput struct { + UserId string +} + +type GetByUserIdOutput struct { + User *domain.User +} diff --git a/application/user/dto/update.go b/application/user/dto/update.go new file mode 100644 index 0000000..ddc9f93 --- /dev/null +++ b/application/user/dto/update.go @@ -0,0 +1,20 @@ +package dto + +import "github.com/labbs/nexo/domain" + +type UpdateProfileInput struct { + UserId string + Username *string + AvatarUrl *string + Preferences *domain.JSONB +} + +type UpdateProfileOutput struct { + User *domain.User +} + +type ChangePasswordInput struct { + UserId string + CurrentPassword string + NewPassword string +} diff --git a/application/user/dto/update_space_order.go b/application/user/dto/update_space_order.go new file mode 100644 index 0000000..97379ca --- /dev/null +++ b/application/user/dto/update_space_order.go @@ -0,0 +1,10 @@ +package dto + +type UpdateSpaceOrderInput struct { + UserId string + SpaceIds []string +} + +type UpdateSpaceOrderOutput struct { + SpaceIds []string +} diff --git a/application/user/favorites.go b/application/user/favorites.go new file mode 100644 index 0000000..098a07e --- /dev/null +++ b/application/user/favorites.go @@ -0,0 +1,89 @@ +package user + +import ( + "fmt" + + "github.com/gofiber/fiber/v2/utils" + "github.com/labbs/nexo/application/user/dto" + "github.com/labbs/nexo/domain" +) + +func (c *UserApp) CreateFavorite(input dto.CreateFavoriteInput) error { + logger := c.Logger.With().Str("component", "application.user.create_favorite").Logger() + + latestPosition, err := c.FavoritePers.GetLatestFavoritePositionByUser(input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get latest favorite position") + return err + } + + favorite := &domain.Favorite{ + Id: utils.UUIDv4(), + DocumentId: input.DocumentId, + SpaceId: input.SpaceId, + UserId: input.UserId, + Position: latestPosition + 1, + } + + err = c.FavoritePers.Create(favorite) + if err != nil { + logger.Error().Err(err).Msg("failed to create favorite") + return err + } + + return nil +} + +func (c *UserApp) DeleteFavorite(input dto.DeleteFavoriteInput) error { + logger := c.Logger.With().Str("component", "application.user.delete_favorite").Logger() + + err := c.FavoritePers.Delete(input.DocumentId, input.UserId, input.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to delete favorite") + return err + } + + return nil +} + +func (c *UserApp) GetMyFavorites(input dto.GetMyFavoritesInput) (*dto.GetMyFavoritesOutput, error) { + logger := c.Logger.With().Str("component", "application.user.get_my_favorites").Logger() + + favorites, err := c.FavoritePers.GetMyFavoritesWithMainDocumentInformations(input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get my favorites") + return nil, err + } + + return &dto.GetMyFavoritesOutput{Favorites: favorites}, nil +} + +func (c *UserApp) GetFavoriteByIdAndUserId(input dto.GetFavoriteByIdAndUserIdInput) (*dto.GetFavoriteByIdAndUserIdOutput, error) { + logger := c.Logger.With().Str("component", "application.user.get_favorite_by_id_and_user_id").Logger() + + favorite, err := c.FavoritePers.GetFavoriteByIdAndUserId(input.FavoriteId, input.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to get favorite by id and user id") + return nil, err + } + + return &dto.GetFavoriteByIdAndUserIdOutput{Favorite: favorite}, nil +} + +func (c *UserApp) UpdateFavoritePosition(input dto.UpdateFavoritePositionInput) (*dto.UpdateFavoritePositionOutput, error) { + logger := c.Logger.With().Str("component", "application.user.update_favorite_position").Logger() + + favorite, err := c.FavoritePers.GetFavoriteByIdAndUserId(input.FavoriteId, input.UserId) + if err != nil || favorite == nil { + logger.Error().Err(err).Msg("failed to get favorite for update position") + return nil, fmt.Errorf("not_found") + } + + favorite.Position = input.NewPosition + if err := c.FavoritePers.UpdateFavoritePosition(favorite); err != nil { + logger.Error().Err(err).Msg("failed to update favorite position") + return nil, err + } + + return &dto.UpdateFavoritePositionOutput{Favorite: favorite}, nil +} diff --git a/application/user/get.go b/application/user/get.go new file mode 100644 index 0000000..ba211ed --- /dev/null +++ b/application/user/get.go @@ -0,0 +1,27 @@ +package user + +import ( + "github.com/labbs/nexo/application/user/dto" +) + +func (c *UserApp) GetByEmail(input dto.GetByEmailInput) (*dto.GetByEmailOutput, error) { + logger := c.Logger.With().Str("component", "application.user.get_by_email").Logger() + + user, err := c.UserPres.GetByEmail(input.Email) + if err != nil { + logger.Error().Err(err).Str("email", input.Email).Msg("failed to get user by email") + return nil, err + } + return &dto.GetByEmailOutput{User: &user}, nil +} + +func (c *UserApp) GetByUserId(input dto.GetByUserIdInput) (*dto.GetByUserIdOutput, error) { + logger := c.Logger.With().Str("component", "application.user.get_by_user_id").Logger() + + user, err := c.UserPres.GetById(input.UserId) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to get user by id") + return nil, err + } + return &dto.GetByUserIdOutput{User: &user}, nil +} diff --git a/application/user/update.go b/application/user/update.go new file mode 100644 index 0000000..7b1d357 --- /dev/null +++ b/application/user/update.go @@ -0,0 +1,68 @@ +package user + +import ( + "fmt" + + "github.com/labbs/nexo/application/user/dto" + "golang.org/x/crypto/bcrypt" +) + +func (c *UserApp) UpdateProfile(input dto.UpdateProfileInput) (*dto.UpdateProfileOutput, error) { + logger := c.Logger.With().Str("component", "application.user.update_profile").Logger() + + user, err := c.UserPres.GetById(input.UserId) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to get user") + return nil, fmt.Errorf("user not found") + } + + if input.Username != nil { + user.Username = *input.Username + } + if input.AvatarUrl != nil { + user.AvatarUrl = *input.AvatarUrl + } + if input.Preferences != nil { + user.Preferences = *input.Preferences + } + + err = c.UserPres.Update(&user) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to update user") + return nil, fmt.Errorf("failed to update profile") + } + + return &dto.UpdateProfileOutput{User: &user}, nil +} + +func (c *UserApp) ChangePassword(input dto.ChangePasswordInput) error { + logger := c.Logger.With().Str("component", "application.user.change_password").Logger() + + user, err := c.UserPres.GetById(input.UserId) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to get user") + return fmt.Errorf("user not found") + } + + // Verify current password + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(input.CurrentPassword)) + if err != nil { + logger.Warn().Str("user_id", input.UserId).Msg("invalid current password") + return fmt.Errorf("invalid current password") + } + + // Hash new password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.NewPassword), bcrypt.DefaultCost) + if err != nil { + logger.Error().Err(err).Msg("failed to hash new password") + return fmt.Errorf("failed to process password") + } + + err = c.UserPres.UpdatePassword(input.UserId, string(hashedPassword)) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to update password") + return fmt.Errorf("failed to update password") + } + + return nil +} diff --git a/application/user/update_space_order.go b/application/user/update_space_order.go new file mode 100644 index 0000000..dca1c93 --- /dev/null +++ b/application/user/update_space_order.go @@ -0,0 +1,38 @@ +package user + +import ( + "fmt" + + "github.com/labbs/nexo/application/user/dto" + "github.com/labbs/nexo/domain" +) + +func (c *UserApp) UpdateSpaceOrder(input dto.UpdateSpaceOrderInput) (*dto.UpdateSpaceOrderOutput, error) { + logger := c.Logger.With().Str("component", "application.user.update_space_order").Logger() + + user, err := c.UserPres.GetById(input.UserId) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to get user") + return nil, fmt.Errorf("user not found") + } + + // Merge space_order into existing preferences (preserve other keys) + if user.Preferences == nil { + user.Preferences = domain.JSONB{} + } + + // Convert []string to []any for JSONB storage + spaceOrderAny := make([]any, len(input.SpaceIds)) + for i, id := range input.SpaceIds { + spaceOrderAny[i] = id + } + user.Preferences["space_order"] = spaceOrderAny + + err = c.UserPres.Update(&user) + if err != nil { + logger.Error().Err(err).Str("user_id", input.UserId).Msg("failed to update space order") + return nil, fmt.Errorf("failed to update space order") + } + + return &dto.UpdateSpaceOrderOutput{SpaceIds: input.SpaceIds}, nil +} diff --git a/application/user/user.go b/application/user/user.go new file mode 100644 index 0000000..30c7b02 --- /dev/null +++ b/application/user/user.go @@ -0,0 +1,25 @@ +package user + +import ( + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type UserApp struct { + Config config.Config + Logger zerolog.Logger + UserPres domain.UserPers + GroupPres domain.GroupPers + FavoritePers domain.FavoritePers +} + +func NewUserApp(config config.Config, logger zerolog.Logger, userPers domain.UserPers, groupPers domain.GroupPers, favoritePers domain.FavoritePers) *UserApp { + return &UserApp{ + Config: config, + Logger: logger, + UserPres: userPers, + GroupPres: groupPers, + FavoritePers: favoritePers, + } +} diff --git a/application/webhook/dto/webhook.go b/application/webhook/dto/webhook.go new file mode 100644 index 0000000..3678d72 --- /dev/null +++ b/application/webhook/dto/webhook.go @@ -0,0 +1,111 @@ +package dto + +import "time" + +// Create webhook +type CreateWebhookInput struct { + UserId string + SpaceId *string + Name string + Url string + Events []string +} + +type CreateWebhookOutput struct { + Id string + Name string + Url string + Secret string + Events []string + Active bool +} + +// List webhooks +type ListWebhooksInput struct { + UserId string +} + +type WebhookItem struct { + Id string + Name string + Url string + SpaceId *string + SpaceName *string + Events []string + Active bool + LastError string + LastErrorAt *time.Time + SuccessCount int + FailureCount int + CreatedAt time.Time +} + +type ListWebhooksOutput struct { + Webhooks []WebhookItem +} + +// Get webhook +type GetWebhookInput struct { + UserId string + WebhookId string +} + +type GetWebhookOutput struct { + Id string + Name string + Url string + Secret string + SpaceId *string + SpaceName *string + Events []string + Active bool + LastError string + LastErrorAt *time.Time + SuccessCount int + FailureCount int + CreatedAt time.Time + UpdatedAt time.Time +} + +// Update webhook +type UpdateWebhookInput struct { + UserId string + WebhookId string + Name *string + Url *string + Events *[]string + Active *bool +} + +// Delete webhook +type DeleteWebhookInput struct { + UserId string + WebhookId string +} + +// Get deliveries +type GetDeliveriesInput struct { + UserId string + WebhookId string + Limit int +} + +type DeliveryItem struct { + Id string + Event string + StatusCode int + Success bool + Duration int + CreatedAt time.Time +} + +type GetDeliveriesOutput struct { + Deliveries []DeliveryItem +} + +// Trigger webhook (internal) +type TriggerWebhookInput struct { + Event string + SpaceId *string + Payload map[string]interface{} +} diff --git a/application/webhook/webhook.go b/application/webhook/webhook.go new file mode 100644 index 0000000..e9b4628 --- /dev/null +++ b/application/webhook/webhook.go @@ -0,0 +1,375 @@ +package webhook + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/labbs/nexo/application/webhook/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type WebhookApp struct { + Config config.Config + Logger zerolog.Logger + WebhookPers domain.WebhookPers + WebhookDeliveryPers domain.WebhookDeliveryPers +} + +func NewWebhookApp(config config.Config, logger zerolog.Logger, webhookPers domain.WebhookPers, webhookDeliveryPers domain.WebhookDeliveryPers) *WebhookApp { + return &WebhookApp{ + Config: config, + Logger: logger, + WebhookPers: webhookPers, + WebhookDeliveryPers: webhookDeliveryPers, + } +} + +// generateSecret generates a secure random secret for webhook signature +func generateSecret() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return "whsec_" + hex.EncodeToString(bytes), nil +} + +// signPayload creates an HMAC-SHA256 signature of the payload +func signPayload(payload []byte, secret string) string { + h := hmac.New(sha256.New, []byte(secret)) + h.Write(payload) + return hex.EncodeToString(h.Sum(nil)) +} + +func (app *WebhookApp) CreateWebhook(input dto.CreateWebhookInput) (*dto.CreateWebhookOutput, error) { + secret, err := generateSecret() + if err != nil { + return nil, fmt.Errorf("failed to generate secret: %w", err) + } + + webhook := &domain.Webhook{ + Id: uuid.New().String(), + UserId: input.UserId, + SpaceId: input.SpaceId, + Name: input.Name, + Url: input.Url, + Secret: secret, + Events: domain.JSONB{ + "events": input.Events, + }, + Active: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if err := app.WebhookPers.Create(webhook); err != nil { + return nil, fmt.Errorf("failed to create webhook: %w", err) + } + + return &dto.CreateWebhookOutput{ + Id: webhook.Id, + Name: webhook.Name, + Url: webhook.Url, + Secret: secret, + Events: input.Events, + Active: webhook.Active, + }, nil +} + +func (app *WebhookApp) ListWebhooks(input dto.ListWebhooksInput) (*dto.ListWebhooksOutput, error) { + webhooks, err := app.WebhookPers.GetByUserId(input.UserId) + if err != nil { + return nil, fmt.Errorf("failed to list webhooks: %w", err) + } + + output := &dto.ListWebhooksOutput{ + Webhooks: make([]dto.WebhookItem, len(webhooks)), + } + + for i, w := range webhooks { + var events []string + if w.Events != nil { + if e, ok := w.Events["events"].([]interface{}); ok { + for _, ev := range e { + if str, ok := ev.(string); ok { + events = append(events, str) + } + } + } + } + + item := dto.WebhookItem{ + Id: w.Id, + Name: w.Name, + Url: w.Url, + SpaceId: w.SpaceId, + Events: events, + Active: w.Active, + LastError: w.LastError, + LastErrorAt: w.LastErrorAt, + SuccessCount: w.SuccessCount, + FailureCount: w.FailureCount, + CreatedAt: w.CreatedAt, + } + + if w.Space != nil { + item.SpaceName = &w.Space.Name + } + + output.Webhooks[i] = item + } + + return output, nil +} + +func (app *WebhookApp) GetWebhook(input dto.GetWebhookInput) (*dto.GetWebhookOutput, error) { + webhook, err := app.WebhookPers.GetById(input.WebhookId) + if err != nil { + return nil, fmt.Errorf("webhook not found: %w", err) + } + + if webhook.UserId != input.UserId { + return nil, fmt.Errorf("access denied") + } + + var events []string + if webhook.Events != nil { + if e, ok := webhook.Events["events"].([]interface{}); ok { + for _, ev := range e { + if str, ok := ev.(string); ok { + events = append(events, str) + } + } + } + } + + output := &dto.GetWebhookOutput{ + Id: webhook.Id, + Name: webhook.Name, + Url: webhook.Url, + Secret: webhook.Secret, + SpaceId: webhook.SpaceId, + Events: events, + Active: webhook.Active, + LastError: webhook.LastError, + LastErrorAt: webhook.LastErrorAt, + SuccessCount: webhook.SuccessCount, + FailureCount: webhook.FailureCount, + CreatedAt: webhook.CreatedAt, + UpdatedAt: webhook.UpdatedAt, + } + + if webhook.Space != nil { + output.SpaceName = &webhook.Space.Name + } + + return output, nil +} + +func (app *WebhookApp) UpdateWebhook(input dto.UpdateWebhookInput) error { + webhook, err := app.WebhookPers.GetById(input.WebhookId) + if err != nil { + return fmt.Errorf("webhook not found: %w", err) + } + + if webhook.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if input.Name != nil { + webhook.Name = *input.Name + } + + if input.Url != nil { + webhook.Url = *input.Url + } + + if input.Events != nil { + webhook.Events = domain.JSONB{ + "events": *input.Events, + } + } + + if input.Active != nil { + webhook.Active = *input.Active + } + + webhook.UpdatedAt = time.Now() + + if err := app.WebhookPers.Update(webhook); err != nil { + return fmt.Errorf("failed to update webhook: %w", err) + } + + return nil +} + +func (app *WebhookApp) DeleteWebhook(input dto.DeleteWebhookInput) error { + webhook, err := app.WebhookPers.GetById(input.WebhookId) + if err != nil { + return fmt.Errorf("webhook not found: %w", err) + } + + if webhook.UserId != input.UserId { + return fmt.Errorf("access denied") + } + + if err := app.WebhookPers.Delete(input.WebhookId); err != nil { + return fmt.Errorf("failed to delete webhook: %w", err) + } + + return nil +} + +func (app *WebhookApp) GetDeliveries(input dto.GetDeliveriesInput) (*dto.GetDeliveriesOutput, error) { + // Verify ownership + webhook, err := app.WebhookPers.GetById(input.WebhookId) + if err != nil { + return nil, fmt.Errorf("webhook not found: %w", err) + } + + if webhook.UserId != input.UserId { + return nil, fmt.Errorf("access denied") + } + + limit := input.Limit + if limit <= 0 { + limit = 20 + } + + deliveries, err := app.WebhookDeliveryPers.GetByWebhookId(input.WebhookId, limit) + if err != nil { + return nil, fmt.Errorf("failed to get deliveries: %w", err) + } + + output := &dto.GetDeliveriesOutput{ + Deliveries: make([]dto.DeliveryItem, len(deliveries)), + } + + for i, d := range deliveries { + output.Deliveries[i] = dto.DeliveryItem{ + Id: d.Id, + Event: d.Event, + StatusCode: d.StatusCode, + Success: d.Success, + Duration: d.Duration, + CreatedAt: d.CreatedAt, + } + } + + return output, nil +} + +// TriggerWebhooks triggers all matching webhooks for an event +func (app *WebhookApp) TriggerWebhooks(input dto.TriggerWebhookInput) { + logger := app.Logger.With().Str("component", "webhook.trigger").Str("event", input.Event).Logger() + + webhooks, err := app.WebhookPers.GetActiveByEvent(domain.WebhookEvent(input.Event), input.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to get webhooks for event") + return + } + + for _, webhook := range webhooks { + go app.deliverWebhook(webhook, input.Event, input.Payload) + } +} + +func (app *WebhookApp) deliverWebhook(webhook domain.Webhook, event string, payload map[string]interface{}) { + logger := app.Logger.With(). + Str("component", "webhook.deliver"). + Str("webhook_id", webhook.Id). + Str("event", event). + Logger() + + // Build the webhook payload + webhookPayload := map[string]interface{}{ + "id": uuid.New().String(), + "event": event, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "data": payload, + } + + payloadBytes, err := json.Marshal(webhookPayload) + if err != nil { + logger.Error().Err(err).Msg("failed to marshal payload") + return + } + + // Create signature + signature := signPayload(payloadBytes, webhook.Secret) + + // Make the HTTP request + start := time.Now() + req, err := http.NewRequest("POST", webhook.Url, bytes.NewBuffer(payloadBytes)) + if err != nil { + logger.Error().Err(err).Msg("failed to create request") + app.recordDelivery(webhook.Id, event, webhookPayload, 0, "", false, 0) + _ = app.WebhookPers.RecordFailure(webhook.Id, err.Error()) + return + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Webhook-Signature", signature) + req.Header.Set("X-Webhook-Event", event) + req.Header.Set("X-Webhook-Delivery", webhookPayload["id"].(string)) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + duration := int(time.Since(start).Milliseconds()) + + if err != nil { + logger.Error().Err(err).Msg("failed to deliver webhook") + app.recordDelivery(webhook.Id, event, webhookPayload, 0, err.Error(), false, duration) + _ = app.WebhookPers.RecordFailure(webhook.Id, err.Error()) + return + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + responseBody := "" + if !success { + buf := new(bytes.Buffer) + buf.ReadFrom(resp.Body) + responseBody = buf.String() + if len(responseBody) > 500 { + responseBody = responseBody[:500] + } + } + + app.recordDelivery(webhook.Id, event, webhookPayload, resp.StatusCode, responseBody, success, duration) + + if success { + _ = app.WebhookPers.IncrementSuccess(webhook.Id) + logger.Debug().Int("status_code", resp.StatusCode).Msg("webhook delivered successfully") + } else { + _ = app.WebhookPers.RecordFailure(webhook.Id, fmt.Sprintf("HTTP %d: %s", resp.StatusCode, responseBody)) + logger.Warn().Int("status_code", resp.StatusCode).Msg("webhook delivery failed") + } +} + +func (app *WebhookApp) recordDelivery(webhookId, event string, payload map[string]interface{}, statusCode int, response string, success bool, duration int) { + delivery := &domain.WebhookDelivery{ + Id: uuid.New().String(), + WebhookId: webhookId, + Event: event, + Payload: domain.JSONB(payload), + StatusCode: statusCode, + Response: response, + Success: success, + Duration: duration, + CreatedAt: time.Now(), + } + + if err := app.WebhookDeliveryPers.Create(delivery); err != nil { + app.Logger.Error().Err(err).Msg("failed to record webhook delivery") + } +} diff --git a/cmd/cmd.go b/cmd/cmd.go new file mode 100644 index 0000000..a720c73 --- /dev/null +++ b/cmd/cmd.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "fmt" + "log" + "os" + + genoapi "github.com/labbs/nexo/interfaces/cli/gen-oapi" + "github.com/labbs/nexo/interfaces/cli/migration" + "github.com/labbs/nexo/interfaces/cli/server" + + "github.com/urfave/cli/v3" +) + +var version = "development" + +// main is the entry point of the application. +// It sets up the CLI commands and handles configuration file loading. +func main() { + sources := cli.NewValueSourceChain() + cmd := &cli.Command{ + Name: "Nexo", + Version: version, + Usage: "Nexo CLI - Manage Nexo server and migrations", + Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) { + config := cmd.String("config") + if len(config) > 0 { + configFile := fmt.Sprintf("%s.yaml", cmd.String("config")) + if _, err := os.Stat(configFile); os.IsNotExist(err) { + return ctx, fmt.Errorf("could not load config file: %s", configFile) + } + + sources.Append(cli.Files(configFile)) + return ctx, nil + } + + return ctx, nil + }, + Commands: []*cli.Command{ + server.NewInstance(version), + migration.NewInstance(version), + genoapi.NewInstance(version), + }, + } + + if err := cmd.Run(context.Background(), os.Args); err != nil { + log.Fatalf("Error running command: %v", err) + } +} diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 0000000..78cde9e --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,9 @@ +http: + port: 8080 + logs: true +logger: + level: info + pretty: false +database: + dialect: sqlite + dsn: ./database.sqlite \ No newline at end of file diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..823d52d --- /dev/null +++ b/config.yaml @@ -0,0 +1,9 @@ +http: + port: 8080 + logs: true +logger: + level: debug + pretty: true +database: + dialect: sqlite + dsn: ./database.sqlite \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100755 index 0000000..4862207 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,10 @@ +services: + db: + image: postgres + restart: always + environment: + POSTGRES_USER: local + POSTGRES_PASSWORD: local + POSTGRES_DB: local + ports: + - 5432:5432 diff --git a/domain/action.go b/domain/action.go new file mode 100644 index 0000000..8fb90db --- /dev/null +++ b/domain/action.go @@ -0,0 +1,135 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +// Action represents an automation rule +type Action struct { + Id string + + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + // Optional: scope action to a specific space or database + SpaceId *string + Space *Space `gorm:"foreignKey:SpaceId;references:Id"` + DatabaseId *string + + Name string + Description string + + // Trigger configuration + TriggerType ActionTriggerType + TriggerConfig JSONB // Trigger-specific configuration + + // Action steps to execute + Steps JSONB // [{type, config}] + + // Status + Active bool + LastRunAt *time.Time + LastError string + RunCount int + SuccessCount int + FailureCount int + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (a *Action) TableName() string { + return "action" +} + +// ActionTriggerType defines when an action should be triggered +type ActionTriggerType string + +const ( + // Document triggers + TriggerDocumentCreated ActionTriggerType = "document.created" + TriggerDocumentUpdated ActionTriggerType = "document.updated" + TriggerDocumentDeleted ActionTriggerType = "document.deleted" + TriggerDocumentMoved ActionTriggerType = "document.moved" + TriggerDocumentShared ActionTriggerType = "document.shared" + + // Database triggers + TriggerRowCreated ActionTriggerType = "row.created" + TriggerRowUpdated ActionTriggerType = "row.updated" + TriggerRowDeleted ActionTriggerType = "row.deleted" + TriggerPropertyChanged ActionTriggerType = "property.changed" + + // Comment triggers + TriggerCommentCreated ActionTriggerType = "comment.created" + TriggerCommentResolved ActionTriggerType = "comment.resolved" + + // Schedule triggers + TriggerSchedule ActionTriggerType = "schedule" +) + +// ActionStepType defines the types of actions that can be executed +type ActionStepType string + +const ( + // Notification actions + StepSendEmail ActionStepType = "send_email" + StepSendSlack ActionStepType = "send_slack" + StepSendWebhook ActionStepType = "send_webhook" + + // Document actions + StepCreateDocument ActionStepType = "create_document" + StepUpdateDocument ActionStepType = "update_document" + StepMoveDocument ActionStepType = "move_document" + StepDuplicateDocument ActionStepType = "duplicate_document" + + // Database actions + StepCreateRow ActionStepType = "create_row" + StepUpdateRow ActionStepType = "update_row" + StepDeleteRow ActionStepType = "delete_row" + StepUpdateProperty ActionStepType = "update_property" + + // Misc actions + StepAddComment ActionStepType = "add_comment" + StepAssignUser ActionStepType = "assign_user" + StepSetReminder ActionStepType = "set_reminder" +) + +type ActionPers interface { + Create(action *Action) error + GetById(id string) (*Action, error) + GetByUserId(userId string) ([]Action, error) + GetActiveByTrigger(triggerType ActionTriggerType, spaceId *string, databaseId *string) ([]Action, error) + Update(action *Action) error + Delete(id string) error + IncrementSuccess(id string) error + RecordFailure(id string, errorMsg string) error + UpdateLastRun(id string) error +} + +// ActionRun records individual action execution +type ActionRun struct { + Id string + + ActionId string + Action Action `gorm:"foreignKey:ActionId;references:Id"` + + TriggerData JSONB // Data that triggered the action + StepsResult JSONB // Result of each step + Success bool + Error string + Duration int // milliseconds + + CreatedAt time.Time +} + +func (r *ActionRun) TableName() string { + return "action_run" +} + +type ActionRunPers interface { + Create(run *ActionRun) error + GetByActionId(actionId string, limit int) ([]ActionRun, error) +} diff --git a/domain/api_key.go b/domain/api_key.go new file mode 100644 index 0000000..1acfae0 --- /dev/null +++ b/domain/api_key.go @@ -0,0 +1,72 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +type ApiKey struct { + Id string + + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + Name string + KeyHash string // Hashed API key (never store plain text) + KeyPrefix string // First 8 chars for identification (e.g., "zk_abc123") + Permissions JSONB // Scopes: ["read:documents", "write:documents", etc.] + + LastUsedAt *time.Time + ExpiresAt *time.Time + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (k *ApiKey) TableName() string { + return "api_key" +} + +// ApiKeyScope defines available permission scopes +type ApiKeyScope string + +const ( + ApiKeyScopeReadDocuments ApiKeyScope = "read:documents" + ApiKeyScopeWriteDocuments ApiKeyScope = "write:documents" + ApiKeyScopeReadSpaces ApiKeyScope = "read:spaces" + ApiKeyScopeWriteSpaces ApiKeyScope = "write:spaces" + ApiKeyScopeReadComments ApiKeyScope = "read:comments" + ApiKeyScopeWriteComments ApiKeyScope = "write:comments" + ApiKeyScopeManageWebhooks ApiKeyScope = "manage:webhooks" + ApiKeyScopeManageDatabases ApiKeyScope = "manage:databases" +) + +func (k *ApiKey) HasScope(scope ApiKeyScope) bool { + if k.Permissions == nil { + return false + } + scopes, ok := k.Permissions["scopes"].([]interface{}) + if !ok { + return false + } + for _, s := range scopes { + if str, ok := s.(string); ok && str == string(scope) { + return true + } + } + return false +} + +type ApiKeyPers interface { + Create(apiKey *ApiKey) error + GetById(id string) (*ApiKey, error) + GetByKeyHash(keyHash string) (*ApiKey, error) + GetByUserId(userId string) ([]ApiKey, error) + Update(apiKey *ApiKey) error + Delete(id string) error + UpdateLastUsed(id string) error + // Admin methods + GetAll(limit, offset int) ([]ApiKey, int64, error) +} diff --git a/domain/comment.go b/domain/comment.go new file mode 100644 index 0000000..8fee7a3 --- /dev/null +++ b/domain/comment.go @@ -0,0 +1,44 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +type Comment struct { + Id string + + DocumentId string + Document Document `gorm:"foreignKey:DocumentId;references:Id"` + + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + // For replies - optional parent comment + ParentId *string + Parent *Comment `gorm:"foreignKey:ParentId;references:Id"` + + Content string + + // For inline comments - optional block reference + BlockId *string + + Resolved bool + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (c *Comment) TableName() string { + return "comment" +} + +type CommentPers interface { + Create(comment *Comment) error + GetById(commentId string) (*Comment, error) + GetByDocumentId(documentId string) ([]Comment, error) + Update(comment *Comment) error + Delete(commentId string) error + Resolve(commentId string, resolved bool) error +} diff --git a/domain/common.go b/domain/common.go new file mode 100644 index 0000000..62bd34f --- /dev/null +++ b/domain/common.go @@ -0,0 +1,38 @@ +package domain + +import ( + "database/sql/driver" + "encoding/json" +) + +// JSONB is a map of strings to interfaces +type JSONB map[string]any + +// Value implements the driver.Valuer interface +func (j JSONB) Value() (driver.Value, error) { + valueString, err := json.Marshal(j) + return string(valueString), err +} + +// Scan implements the sql.Scanner interface +func (j *JSONB) Scan(value any) error { + return json.Unmarshal([]byte(value.(string)), j) +} + +// JSONBArray is a slice that can be stored as JSONB in PostgreSQL +type JSONBArray []any + +// Value implements the driver.Valuer interface +func (j JSONBArray) Value() (driver.Value, error) { + valueString, err := json.Marshal(j) + return string(valueString), err +} + +// Scan implements the sql.Scanner interface +func (j *JSONBArray) Scan(value any) error { + if value == nil { + *j = nil + return nil + } + return json.Unmarshal([]byte(value.(string)), j) +} diff --git a/domain/database.go b/domain/database.go new file mode 100644 index 0000000..b5782a2 --- /dev/null +++ b/domain/database.go @@ -0,0 +1,173 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +// DatabaseType defines the types of databases +type DatabaseType string + +const ( + DatabaseTypeSpreadsheet DatabaseType = "spreadsheet" + DatabaseTypeDocument DatabaseType = "document" +) + +// Database represents a Notion-like database (table) +type Database struct { + Id string + + SpaceId string + Space Space `gorm:"foreignKey:SpaceId;references:Id"` + + // Optional: database can be inline in a document + DocumentId *string + Document *Document `gorm:"foreignKey:DocumentId;references:Id"` + + Name string + Description string + Icon string + + // Schema defines the columns/properties of the database + Schema JSONBArray // [{id, name, type, options}] + + // Views configuration (table, board, calendar, etc.) + Views JSONBArray // [{id, name, type, filter, sort, columns}] + + // Default view type + DefaultView string + + // Type of database: "spreadsheet" or "document" + Type DatabaseType + + Position int + + CreatedBy string + User User `gorm:"foreignKey:CreatedBy;references:Id"` + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (d *Database) TableName() string { + return "database" +} + +// PropertyType defines the types of properties/columns +type PropertyType string + +const ( + PropertyTypeTitle PropertyType = "title" + PropertyTypeText PropertyType = "text" + PropertyTypeNumber PropertyType = "number" + PropertyTypeSelect PropertyType = "select" + PropertyTypeMultiSelect PropertyType = "multi_select" + PropertyTypeDate PropertyType = "date" + PropertyTypeCheckbox PropertyType = "checkbox" + PropertyTypeUrl PropertyType = "url" + PropertyTypeEmail PropertyType = "email" + PropertyTypePhone PropertyType = "phone" + PropertyTypeRelation PropertyType = "relation" + PropertyTypeRollup PropertyType = "rollup" + PropertyTypeFormula PropertyType = "formula" + PropertyTypeCreatedTime PropertyType = "created_time" + PropertyTypeUpdatedTime PropertyType = "updated_time" + PropertyTypeCreatedBy PropertyType = "created_by" + PropertyTypeUpdatedBy PropertyType = "updated_by" + PropertyTypeFiles PropertyType = "files" + PropertyTypePerson PropertyType = "person" +) + +// ViewType defines the types of database views +type ViewType string + +const ( + ViewTypeTable ViewType = "table" + ViewTypeBoard ViewType = "board" + ViewTypeCalendar ViewType = "calendar" + ViewTypeGallery ViewType = "gallery" + ViewTypeList ViewType = "list" + ViewTypeTimeline ViewType = "timeline" +) + +type DatabasePers interface { + Create(database *Database) error + GetById(id string) (*Database, error) + GetBySpaceId(spaceId string) ([]Database, error) + GetByDocumentId(documentId string) ([]Database, error) + Update(database *Database) error + Delete(id string) error + Search(query string, userId string, spaceId *string, limit int) ([]Database, error) +} + +// DatabaseRow represents a row/page in a database +type DatabaseRow struct { + Id string + + DatabaseId string + Database Database `gorm:"foreignKey:DatabaseId;references:Id"` + + // Properties holds the values for each column + Properties JSONB // {propertyId: value} + + // Row can optionally have page content + Content JSONB + + // ShowInSidebar indicates if this row should appear in sidebar (for document databases) + ShowInSidebar bool + + CreatedBy string + CreatedUser User `gorm:"foreignKey:CreatedBy;references:Id"` + + UpdatedBy string + UpdatedUser User `gorm:"foreignKey:UpdatedBy;references:Id"` + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (r *DatabaseRow) TableName() string { + return "database_row" +} + +// FilterRule defines a single filter condition +type FilterRule struct { + Property string `json:"property"` + Condition string `json:"condition"` // eq, neq, gt, lt, gte, lte, contains, is_empty, is_not_empty + Value interface{} `json:"value,omitempty"` +} + +// FilterConfig defines the filter configuration with AND/OR groups +type FilterConfig struct { + And []FilterRule `json:"and,omitempty"` + Or []FilterRule `json:"or,omitempty"` +} + +// SortRule defines a sort condition +type SortRule struct { + PropertyId string `json:"property_id"` + Direction string `json:"direction"` // "asc" or "desc" +} + +// RowQueryOptions contains filter and sort options for row queries +type RowQueryOptions struct { + Filter *FilterConfig + Sort []SortRule + Limit int + Offset int +} + +type DatabaseRowPers interface { + Create(row *DatabaseRow) error + GetById(id string) (*DatabaseRow, error) + GetByDatabaseId(databaseId string, limit, offset int) ([]DatabaseRow, error) + GetByDatabaseIdWithOptions(databaseId string, options RowQueryOptions) ([]DatabaseRow, error) + GetRowCount(databaseId string) (int64, error) + GetRowCountWithFilter(databaseId string, filter *FilterConfig) (int64, error) + Update(row *DatabaseRow) error + Delete(id string) error + BulkDelete(ids []string) error +} diff --git a/domain/document.go b/domain/document.go new file mode 100644 index 0000000..87aec1e --- /dev/null +++ b/domain/document.go @@ -0,0 +1,154 @@ +package domain + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +type Document struct { + Id string + Name string + Slug string + + Config DocumentConfig + Metadata JSONB + + ParentId *string + Parent *Document `gorm:"foreignKey:ParentId;references:Id"` + + SpaceId string + Space Space `gorm:"foreignKey:SpaceId;references:Id"` + + Public bool + + // Permissions spécifiques au document (optionnelles) + Permissions []Permission `gorm:"foreignKey:DocumentId;references:Id"` + + Content datatypes.JSON + + Position int + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (d *Document) TableName() string { + return "document" +} + +type DocumentConfig struct { + FullWidth bool `json:"full_width"` + Icon string `json:"icon"` + Lock bool `json:"lock"` + HeaderBackground string `json:"header_background"` +} + +// Value implements the driver.Valuer interface +func (dc DocumentConfig) Value() (driver.Value, error) { + return json.Marshal(dc) +} + +// Scan implements the sql.Scanner interface +func (dc *DocumentConfig) Scan(value any) error { + switch v := value.(type) { + case []byte: + // PostgreSQL usually returns []byte + return json.Unmarshal(v, dc) + case string: + // SQLite often returns string + return json.Unmarshal([]byte(v), dc) + case nil: + // Handle null case + *dc = DocumentConfig{} + return nil + default: + // Fall back to string conversion + data, err := json.Marshal(v) + if err != nil { + return err + } + return json.Unmarshal(data, dc) + } +} + +func (d *Document) HasPermission(userId string, requiredRole PermissionRole) bool { + // 1. Vérifier les permissions spécifiques au document d'abord + for _, perm := range d.Permissions { + if perm.UserId != nil && *perm.UserId == userId { + if perm.Role == PermissionRoleDenied { + return false // Refus explicite + } + return d.documentRoleHasPermission(perm.Role, requiredRole) + } + } + + // 2. Si pas de permission spécifique, hériter du space + if d.Space.HasPermission(userId, PermissionRoleViewer) { + // Si l'user a accès au space, il peut au moins voir le document + if requiredRole == PermissionRoleViewer { + return true + } + // Pour éditer, il faut au moins être editor du space + if requiredRole == PermissionRoleEditor { + return d.Space.HasPermission(userId, PermissionRoleEditor) + } + } + + return false +} + +func (d *Document) documentRoleHasPermission(userRole, requiredRole PermissionRole) bool { + roleHierarchy := map[PermissionRole]int{ + PermissionRoleViewer: 1, + PermissionRoleEditor: 2, + PermissionRoleOwner: 3, + } + return roleHierarchy[userRole] >= roleHierarchy[requiredRole] +} + +// CanManagePermissions returns true if the user can manage document permissions +// This requires being owner of the document OR admin/owner of the space +func (d *Document) CanManagePermissions(userId string) bool { + // Check if user is owner of this document + for _, perm := range d.Permissions { + if perm.UserId != nil && *perm.UserId == userId && perm.Role == PermissionRoleOwner { + return true + } + } + + // Check if user is admin or owner of the space + return d.Space.HasPermission(userId, PermissionRoleAdmin) +} + +type DocumentPers interface { + GetDocumentWithPermissions(documentId, userId string) (*Document, error) + GetDocumentByIdOrSlugWithUserPermissions(spaceId string, id *string, slug *string, userId string) (*Document, error) + GetRootDocumentsFromSpaceWithUserPermissions(spaceId, userId string) ([]Document, error) + GetChildDocumentsWithUserPermissions(parentId, userId string) ([]Document, error) + Create(document *Document, userId string) error + Update(document *Document, userId string) error + Delete(documentId, userId string) error + Move(documentId string, newParentId *string, userId string) (*Document, error) + // Trash management + GetDeletedDocuments(spaceId, userId string) ([]Document, error) + Restore(documentId, userId string) error + // Public sharing + SetPublic(documentId string, public bool, userId string) error + GetPublicDocument(spaceId string, id *string, slug *string) (*Document, error) + // Search + Search(query string, userId string, spaceId *string, limit int) ([]Document, error) + // Reorder + Reorder(spaceId string, items []ReorderItem, userId string) error + GetMaxPosition(spaceId string, parentId *string) (int, error) +} + +// ReorderItem represents a single item in a reorder request +type ReorderItem struct { + Id string + Position int +} diff --git a/domain/document_version.go b/domain/document_version.go new file mode 100644 index 0000000..0548ec7 --- /dev/null +++ b/domain/document_version.go @@ -0,0 +1,44 @@ +package domain + +import ( + "time" + + "gorm.io/datatypes" +) + +type DocumentVersion struct { + Id string + + DocumentId string + Document Document `gorm:"foreignKey:DocumentId;references:Id"` + + // User who created this version + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + // Version number (auto-incremented per document) + Version int + + // Snapshot of document at this version + Name string + Content datatypes.JSON + Config DocumentConfig + + // Optional description of changes + Description string + + CreatedAt time.Time +} + +func (v *DocumentVersion) TableName() string { + return "document_version" +} + +type DocumentVersionPers interface { + Create(version *DocumentVersion) error + GetByDocumentId(documentId string, limit int, offset int) ([]DocumentVersion, error) + GetById(versionId string) (*DocumentVersion, error) + GetLatestVersion(documentId string) (*DocumentVersion, error) + GetVersionCount(documentId string) (int64, error) + DeleteOldVersions(documentId string, keepCount int) error +} diff --git a/domain/drawing.go b/domain/drawing.go new file mode 100644 index 0000000..4298dea --- /dev/null +++ b/domain/drawing.go @@ -0,0 +1,52 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +// Drawing represents an Excalidraw drawing +type Drawing struct { + Id string + + SpaceId string + Space Space `gorm:"foreignKey:SpaceId;references:Id"` + + // Optional: drawing can be inline in a document + DocumentId *string + Document *Document `gorm:"foreignKey:DocumentId;references:Id"` + + Name string + Icon string // Emoji icon for the drawing + + // Excalidraw data + Elements JSONBArray // Excalidraw elements array + AppState JSONB // Excalidraw appState + Files JSONB // Embedded files (images in base64) + + // Base64 PNG thumbnail for preview + Thumbnail string + + Position int + + CreatedBy string + User User `gorm:"foreignKey:CreatedBy;references:Id"` + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (d *Drawing) TableName() string { + return "drawing" +} + +type DrawingPers interface { + Create(drawing *Drawing) error + GetById(id string) (*Drawing, error) + GetBySpaceId(spaceId string) ([]Drawing, error) + GetByDocumentId(documentId string) ([]Drawing, error) + Update(drawing *Drawing) error + Delete(id string) error +} diff --git a/domain/favorite.go b/domain/favorite.go new file mode 100644 index 0000000..9ad27c1 --- /dev/null +++ b/domain/favorite.go @@ -0,0 +1,35 @@ +package domain + +import ( + "time" +) + +type Favorite struct { + Id string + + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + DocumentId string + Document Document `gorm:"foreignKey:DocumentId;references:Id"` + + SpaceId string + Space Space `gorm:"foreignKey:SpaceId;references:Id"` + + Position int + + CreatedAt time.Time +} + +func (f *Favorite) TableName() string { + return "favorite" +} + +type FavoritePers interface { + GetLatestFavoritePositionByUser(userId string) (int, error) + Create(favorite *Favorite) error + Delete(documentId, userId string, spaceId string) error + GetMyFavoritesWithMainDocumentInformations(userId string) ([]Favorite, error) + UpdateFavoritePosition(favorite *Favorite) error + GetFavoriteByIdAndUserId(favoriteId, userId string) (*Favorite, error) +} diff --git a/domain/group.go b/domain/group.go new file mode 100644 index 0000000..e1ceb23 --- /dev/null +++ b/domain/group.go @@ -0,0 +1,45 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +type Group struct { + Id string + Name string + Description string + + Role Role `gorm:"type:role;default:'user'"` + + // Owner is the username of the user who owns the group + OwnerId string + // OwnerUser is the user who owns the group + OwnerUser User `gorm:"foreignKey:OwnerId;references:Id"` + + // Members is the list of users who are members of the group + Members []User `gorm:"many2many:group_members;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (g *Group) TableName() string { + return "group" +} + +// GroupPers defines the persistence interface for groups +type GroupPers interface { + Create(group *Group) error + GetById(groupId string) (*Group, error) + GetAll(limit, offset int) ([]Group, int64, error) + Update(group *Group) error + Delete(groupId string) error + + // Member management + AddMember(groupId, userId string) error + RemoveMember(groupId, userId string) error + GetMembers(groupId string) ([]User, error) +} diff --git a/domain/member.go b/domain/member.go new file mode 100644 index 0000000..6f07ad0 --- /dev/null +++ b/domain/member.go @@ -0,0 +1,34 @@ +package domain + +type Members []Member +type MembersWithUsersOrGroups []MemberWithUsersOrGroups + +type MemberType string +type AccessType string + +type Member struct { + Id string + Type MemberType + Access AccessType +} + +// AccessTypeViewer is the access type viewer +const ( + AccessTypeViewer AccessType = "viewer" + AccessTypeEditor AccessType = "editor" + AccessTypeComment AccessType = "comment" + AccessTypeFull AccessType = "full" +) + +// MemberType is the type of member +const ( + MemberTypeUser MemberType = "user" + MemberTypeGroup MemberType = "group" +) + +// MemberWithUser is a model for a member with user information +type MemberWithUsersOrGroups struct { + Member + User User + Group Group +} diff --git a/domain/permission.go b/domain/permission.go new file mode 100644 index 0000000..e13b368 --- /dev/null +++ b/domain/permission.go @@ -0,0 +1,74 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +// PermissionType defines the type of resource the permission applies to +type PermissionType string + +const ( + PermissionTypeSpace PermissionType = "space" + PermissionTypeDocument PermissionType = "document" + PermissionTypeDatabase PermissionType = "database" + PermissionTypeDrawing PermissionType = "drawing" +) + +// PermissionRole defines the role/access level +type PermissionRole string + +const ( + PermissionRoleOwner PermissionRole = "owner" // Full control, can manage permissions + PermissionRoleAdmin PermissionRole = "admin" // Admin access (for spaces) + PermissionRoleEditor PermissionRole = "editor" // Can edit + PermissionRoleViewer PermissionRole = "viewer" // Read-only + PermissionRoleDenied PermissionRole = "denied" // Explicitly deny access +) + +// Permission represents a unified permission entry for any resource type +type Permission struct { + Id string + Type PermissionType // "space", "document", "database", "drawing" + + // Resource IDs - only one should be set based on Type + SpaceId *string + Space *Space `gorm:"foreignKey:SpaceId;references:Id"` + DocumentId *string + Document *Document `gorm:"foreignKey:DocumentId;references:Id"` + DatabaseId *string + Database *Database `gorm:"foreignKey:DatabaseId;references:Id"` + DrawingId *string + Drawing *Drawing `gorm:"foreignKey:DrawingId;references:Id"` + + // Target - either a user or a group + UserId *string + User *User `gorm:"foreignKey:UserId;references:Id"` + + GroupId *string + Group *Group `gorm:"foreignKey:GroupId;references:Id"` + + Role PermissionRole + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (p *Permission) TableName() string { + return "permission" +} + +// PermissionPers is the persistence interface for permissions +type PermissionPers interface { + // Generic methods + ListByResource(resourceType PermissionType, resourceId string) ([]Permission, error) + GetByResourceAndUser(resourceType PermissionType, resourceId, userId string) (*Permission, error) + GetByResourceAndGroup(resourceType PermissionType, resourceId, groupId string) (*Permission, error) + UpsertUser(resourceType PermissionType, resourceId, userId string, role PermissionRole) error + UpsertGroup(resourceType PermissionType, resourceId, groupId string, role PermissionRole) error + DeleteUser(resourceType PermissionType, resourceId, userId string) error + DeleteGroup(resourceType PermissionType, resourceId, groupId string) error +} + diff --git a/domain/role.go b/domain/role.go new file mode 100644 index 0000000..66e8602 --- /dev/null +++ b/domain/role.go @@ -0,0 +1,9 @@ +package domain + +type Role string + +const ( + RoleUser Role = "user" + RoleAdmin Role = "admin" + RoleGest Role = "guest" +) diff --git a/domain/session.go b/domain/session.go new file mode 100644 index 0000000..f7daa3f --- /dev/null +++ b/domain/session.go @@ -0,0 +1,26 @@ +package domain + +import "time" + +type Session struct { + Id string + UserId string + + User User `gorm:"foreignKey:UserId;references:Id"` + UserAgent string + IpAddress string + ExpiresAt time.Time + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (s *Session) TableName() string { + return "session" +} + +type SessionPers interface { + Create(session *Session) error + GetById(id string) (*Session, error) + DeleteById(id string) error +} diff --git a/domain/space.go b/domain/space.go new file mode 100644 index 0000000..2b4d5b8 --- /dev/null +++ b/domain/space.go @@ -0,0 +1,94 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +type Space struct { + Id string + Name string + + Slug string + Icon string + IconColor string + + Type SpaceType + + OwnerId *string + Owner *User `gorm:"foreignKey:OwnerId;references:Id"` + + Documents []Document `gorm:"foreignKey:SpaceId;references:Id"` + + Permissions []Permission `gorm:"foreignKey:SpaceId;references:Id"` + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +// SpaceType is the type of space +type SpaceType string + +const ( + // SpaceTypePublic is the public space type + SpaceTypePublic SpaceType = "public" + // SpaceTypePrivate is the private space type + SpaceTypePrivate SpaceType = "private" + // SpaceTypeRestricted is the restricted space type + SpaceTypeRestricted SpaceType = "restricted" + // SpaceTypePersonal is the personal space type + SpaceTypePersonal SpaceType = "personal" +) + +func (s *Space) TableName() string { + return "space" +} + +func (s *Space) GetUserRole(userId string) *PermissionRole { + // Check if the user is the owner + if s.OwnerId != nil && *s.OwnerId == userId { + role := PermissionRoleOwner + return &role + } + + // Check permissions + for _, perm := range s.Permissions { + if perm.UserId != nil && *perm.UserId == userId { + return &perm.Role + } + } + + return nil +} + +func (s *Space) HasPermission(userId string, requiredRole PermissionRole) bool { + userRole := s.GetUserRole(userId) + if userRole == nil { + // For public spaces, allow reading + return s.Type == SpaceTypePublic && requiredRole == PermissionRoleViewer + } + + return s.roleHasPermission(*userRole, requiredRole) +} + +func (s *Space) roleHasPermission(userRole, requiredRole PermissionRole) bool { + roleHierarchy := map[PermissionRole]int{ + PermissionRoleViewer: 1, + PermissionRoleEditor: 2, + PermissionRoleAdmin: 3, + PermissionRoleOwner: 4, + } + return roleHierarchy[userRole] >= roleHierarchy[requiredRole] +} + +type SpacePers interface { + Create(space *Space) error + GetSpacesForUser(userId string) ([]Space, error) + GetSpaceById(spaceId string) (*Space, error) + Update(space *Space) error + Delete(spaceId string) error + // Admin methods + GetAll(limit, offset int) ([]Space, int64, error) +} diff --git a/domain/user.go b/domain/user.go new file mode 100644 index 0000000..c2968d7 --- /dev/null +++ b/domain/user.go @@ -0,0 +1,41 @@ +package domain + +import ( + "time" +) + +type User struct { + Id string + Username string + Email string + Password string + + AvatarUrl string + Preferences JSONB + Active bool + + Role Role `gorm:"type:role;default:'user'"` + + Favorites []Favorite `gorm:"foreignKey:UserId;references:Id"` + + CreatedAt time.Time + UpdatedAt time.Time +} + +func (u *User) TableName() string { + return "user" +} + +type UserPers interface { + GetByUsername(username string) (User, error) + GetByEmail(email string) (User, error) + GetById(id string) (User, error) + Create(user User) (User, error) + Update(user *User) error + UpdatePassword(userId, hashedPassword string) error + // Admin methods + GetAll(limit, offset int) ([]User, int64, error) + UpdateRole(userId string, role Role) error + UpdateActive(userId string, active bool) error + Delete(userId string) error +} diff --git a/domain/webhook.go b/domain/webhook.go new file mode 100644 index 0000000..6973b1a --- /dev/null +++ b/domain/webhook.go @@ -0,0 +1,106 @@ +package domain + +import ( + "time" + + "gorm.io/gorm" +) + +type Webhook struct { + Id string + + UserId string + User User `gorm:"foreignKey:UserId;references:Id"` + + // Optional: scope webhook to a specific space + SpaceId *string + Space *Space `gorm:"foreignKey:SpaceId;references:Id"` + + Name string + Url string + Secret string // Used for signature verification + + // Events to trigger on + Events JSONB // ["document.created", "document.updated", etc.] + + // Status + Active bool + LastError string + LastErrorAt *time.Time + SuccessCount int + FailureCount int + + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt +} + +func (w *Webhook) TableName() string { + return "webhook" +} + +// Webhook events +type WebhookEvent string + +const ( + WebhookEventDocumentCreated WebhookEvent = "document.created" + WebhookEventDocumentUpdated WebhookEvent = "document.updated" + WebhookEventDocumentDeleted WebhookEvent = "document.deleted" + WebhookEventCommentCreated WebhookEvent = "comment.created" + WebhookEventCommentResolved WebhookEvent = "comment.resolved" + WebhookEventSpaceCreated WebhookEvent = "space.created" + WebhookEventSpaceUpdated WebhookEvent = "space.updated" +) + +func (w *Webhook) HasEvent(event WebhookEvent) bool { + if w.Events == nil { + return false + } + events, ok := w.Events["events"].([]interface{}) + if !ok { + return false + } + for _, e := range events { + if str, ok := e.(string); ok && str == string(event) { + return true + } + } + return false +} + +type WebhookPers interface { + Create(webhook *Webhook) error + GetById(id string) (*Webhook, error) + GetByUserId(userId string) ([]Webhook, error) + GetActiveByEvent(event WebhookEvent, spaceId *string) ([]Webhook, error) + Update(webhook *Webhook) error + Delete(id string) error + IncrementSuccess(id string) error + RecordFailure(id string, errorMsg string) error +} + +// WebhookDelivery records individual delivery attempts +type WebhookDelivery struct { + Id string + + WebhookId string + Webhook Webhook `gorm:"foreignKey:WebhookId;references:Id"` + + Event string + Payload JSONB + StatusCode int + Response string + Duration int // milliseconds + Success bool + + CreatedAt time.Time +} + +func (d *WebhookDelivery) TableName() string { + return "webhook_delivery" +} + +type WebhookDeliveryPers interface { + Create(delivery *WebhookDelivery) error + GetByWebhookId(webhookId string, limit int) ([]WebhookDelivery, error) +} diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..67094e5 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +./app migration -c /config/config.yaml +exec ./app server -c /config/config.yaml \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..67c5fc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,60 @@ +module github.com/labbs/nexo + +go 1.25.1 + +require ( + github.com/btcsuite/btcutil v1.0.2 + github.com/go-co-op/gocron/v2 v2.16.6 + github.com/go-playground/validator/v10 v10.30.1 + github.com/gofiber/fiber/v2 v2.52.10 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + github.com/gosimple/slug v1.15.0 + github.com/labbs/fiber-oapi v1.7.4 + github.com/lithammer/shortuuid/v4 v4.2.0 + github.com/pressly/goose/v3 v3.26.0 + github.com/rs/zerolog v1.34.0 + github.com/urfave/cli-altsrc/v3 v3.1.0 + github.com/urfave/cli/v3 v3.4.1 + golang.org/x/crypto v0.46.0 + gorm.io/datatypes v1.2.7 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.0 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/andybalholm/brotli v1.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/gosimple/unidecode v1.0.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.7.5 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.17 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.66.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/mysql v1.5.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be22e1b --- /dev/null +++ b/go.sum @@ -0,0 +1,202 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-co-op/gocron/v2 v2.16.6 h1:zI2Ya9sqvuLcgqJgV79LwoJXM8h20Z/drtB7ATbpRWo= +github.com/go-co-op/gocron/v2 v2.16.6/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/fiber/v2 v2.52.10 h1:jRHROi2BuNti6NYXmZ6gbNSfT3zj/8c0xy94GOU5elY= +github.com/gofiber/fiber/v2 v2.52.10/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo= +github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ= +github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o= +github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= +github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/labbs/fiber-oapi v1.7.4 h1:mPTB/hUXgHMLeBRML6pnu4/Cppjg0X5QNd3EC0dlhZc= +github.com/labbs/fiber-oapi v1.7.4/go.mod h1:vr7Yv3EEGSaKTiuAh5BIjtW/2YsSZTuQJbz/PUNwpXc= +github.com/labbs/fiber-oapi v1.7.5 h1:OD9bZxCK5nD9V64tvymWcF17YQ4ScaL04MfCVmTpsa4= +github.com/labbs/fiber-oapi v1.7.5/go.mod h1:oOAW4i9YQcyuZFUK63IJ6r6r49JOb/MV8GIuyLrdxII= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c= +github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ= +github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs= +github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM= +github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/urfave/cli-altsrc/v3 v3.1.0 h1:6E5+kXeAWmRxXlPgdEVf9VqVoTJ2MJci0UMpUi/w/bA= +github.com/urfave/cli-altsrc/v3 v3.1.0/go.mod h1:VcWVTGXcL3nrXUDJZagHAeUX702La3PKeWav7KpISqA= +github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= +github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU= +github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY= +gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= diff --git a/infrastructure/config/config.go b/infrastructure/config/config.go new file mode 100644 index 0000000..b1b3d46 --- /dev/null +++ b/infrastructure/config/config.go @@ -0,0 +1,54 @@ +package config + +type Config struct { + // Version of the application + Version string + + // ConfigFile is the path to the configuration file + ConfigFile string + + Server struct { + // Port is the server port + Port int + // HttpLogs indicates if HTTP logs are enabled + HttpLogs bool + } + + // Logger is the configuration for the zerolog logger. + // Level is the log level for the logger. + // Pretty enables or disables pretty printing of logs (non JSON logs). + Logger struct { + Level string + Pretty bool + } + + // Database is the configuration for the database connection. + // Dialect is the database engine (sqlite, postgres, etc.). + // DSN is the Data Source Name for the database connection. + Database struct { + Dialect string // Database engine (sqlite, postgres, etc.) + DSN string + } + + Session struct { + SecretKey string + ExpirationMinutes int + Issuer string + } + + Auth struct { + DisableAdminAccount bool + } + + Registration struct { + Enabled bool // Enable or disable user registration + RequireEmailVerification bool // Require email verification for new registrations + DomainWhitelist []string // List of allowed domains for registration + PasswordMinLength int // Minimum password length for registration + PasswordComplexity bool // Require complex passwords (uppercase, lowercase, numbers, symbols) + } + + ExportOapi struct { + FileName string + } +} diff --git a/infrastructure/config/database_flags.go b/infrastructure/config/database_flags.go new file mode 100644 index 0000000..01ce5d9 --- /dev/null +++ b/infrastructure/config/database_flags.go @@ -0,0 +1,34 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func DatabaseFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "database.dialect", + Usage: "The database dialect (e.g., sqlite, postgres, mysql)", + Aliases: []string{"db.dialect"}, + Value: "sqlite", + Destination: &cfg.Database.Dialect, + Sources: cli.NewValueSourceChain( + cli.EnvVar("DATABASE_DIALECT"), + altsrcyaml.YAML("database.dialect", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "database.dsn", + Usage: "The database DSN (Data Source Name)", + Aliases: []string{"db.dsn"}, + Value: "./database.sqlite", + Destination: &cfg.Database.DSN, + Sources: cli.NewValueSourceChain( + cli.EnvVar("DATABASE_DSN"), + altsrcyaml.YAML("database.dsn", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/config/export_oapi_flags.go b/infrastructure/config/export_oapi_flags.go new file mode 100644 index 0000000..bf65410 --- /dev/null +++ b/infrastructure/config/export_oapi_flags.go @@ -0,0 +1,23 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func ExportOapiFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "export-oapi.filename", + Usage: "The output file name for the OpenAPI export", + Aliases: []string{"export.oapi.filename"}, + Value: "openapi.yaml", + Destination: &cfg.ExportOapi.FileName, + Sources: cli.NewValueSourceChain( + cli.EnvVar("EXPORT_OAPI_FILENAME"), + altsrcyaml.YAML("export-oapi.filename", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/config/generic_flags.go b/infrastructure/config/generic_flags.go new file mode 100644 index 0000000..c7c2ee5 --- /dev/null +++ b/infrastructure/config/generic_flags.go @@ -0,0 +1,17 @@ +package config + +import ( + "github.com/urfave/cli/v3" +) + +func GenericFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: "config.yaml", + Usage: "Path to the configuration file", + Destination: &cfg.ConfigFile, + }, + } +} diff --git a/infrastructure/config/http_flags.go b/infrastructure/config/http_flags.go new file mode 100644 index 0000000..a67400b --- /dev/null +++ b/infrastructure/config/http_flags.go @@ -0,0 +1,32 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func ServerFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: "http.port", + Aliases: []string{"p"}, + Value: 8080, + Destination: &cfg.Server.Port, + Sources: cli.NewValueSourceChain( + cli.EnvVar("HTTP_PORT"), + altsrcyaml.YAML("http.port", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "http.logs", + Aliases: []string{"l"}, + Value: false, + Destination: &cfg.Server.HttpLogs, + Sources: cli.NewValueSourceChain( + cli.EnvVar("HTTP_LOGS"), + altsrcyaml.YAML("http.logs", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/config/logger_flags.go b/infrastructure/config/logger_flags.go new file mode 100644 index 0000000..b209713 --- /dev/null +++ b/infrastructure/config/logger_flags.go @@ -0,0 +1,31 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func LoggerFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.StringFlag{ + Name: "logger.level", + Aliases: []string{"l"}, + Value: "info", + Destination: &cfg.Logger.Level, + Sources: cli.NewValueSourceChain( + cli.EnvVar("LOGGER_LEVEL"), + altsrcyaml.YAML("logger.level", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "logger.pretty", + Value: false, + Destination: &cfg.Logger.Pretty, + Sources: cli.NewValueSourceChain( + cli.EnvVar("LOGGER_PRETTY"), + altsrcyaml.YAML("logger.pretty", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/config/registration_flags.go b/infrastructure/config/registration_flags.go new file mode 100644 index 0000000..8b228b2 --- /dev/null +++ b/infrastructure/config/registration_flags.go @@ -0,0 +1,59 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func RegistrationFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.BoolFlag{ + Name: "registration.enabled", + Value: true, + Destination: &cfg.Registration.Enabled, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_ENABLED"), + altsrcyaml.YAML("registration.enabled", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "registration.require_email_verification", + Value: true, + Destination: &cfg.Registration.RequireEmailVerification, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_REQUIRE_EMAIL_VERIFICATION"), + altsrcyaml.YAML("registration.require_email_verification", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringSliceFlag{ + Name: "registration.domain_whitelist", + Value: []string{}, + Destination: &cfg.Registration.DomainWhitelist, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_DOMAIN_WHITELIST"), + altsrcyaml.YAML("registration.domain_whitelist", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + Usage: "List of allowed email domains for registration (comma separated)", + }, + &cli.IntFlag{ + Name: "registration.password_min_length", + Value: 12, + Destination: &cfg.Registration.PasswordMinLength, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_PASSWORD_MIN_LENGTH"), + altsrcyaml.YAML("registration.password_min_length", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.BoolFlag{ + Name: "registration.password_complexity", + Value: true, + Destination: &cfg.Registration.PasswordComplexity, + Sources: cli.NewValueSourceChain( + cli.EnvVar("REGISTRATION_PASSWORD_COMPLEXITY"), + altsrcyaml.YAML("registration.password_complexity", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + Usage: "Require complex passwords (uppercase, lowercase, numbers, symbols)", + }, + } +} diff --git a/infrastructure/config/session_flags.go b/infrastructure/config/session_flags.go new file mode 100644 index 0000000..9075c7b --- /dev/null +++ b/infrastructure/config/session_flags.go @@ -0,0 +1,39 @@ +package config + +import ( + altsrc "github.com/urfave/cli-altsrc/v3" + altsrcyaml "github.com/urfave/cli-altsrc/v3/yaml" + "github.com/urfave/cli/v3" +) + +func SessionFlags(cfg *Config) []cli.Flag { + return []cli.Flag{ + &cli.IntFlag{ + Name: "session.expiration_minutes", + Value: 60 * 24 * 30, // 1 month + Destination: &cfg.Session.ExpirationMinutes, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_EXPIRATION_MINUTES"), + altsrcyaml.YAML("session.expiration_minutes", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "session.secret_key", + Value: "supersecretkey", // In production, use a secure key and do not hardcode it + Destination: &cfg.Session.SecretKey, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_SECRET_KEY"), + altsrcyaml.YAML("session.secret_key", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + &cli.StringFlag{ + Name: "session.issuer", + Value: "nexo", // Issuer name for the session tokens + Destination: &cfg.Session.Issuer, + Sources: cli.NewValueSourceChain( + cli.EnvVar("SESSION_ISSUER"), + altsrcyaml.YAML("session.issuer", altsrc.NewStringPtrSourcer(&cfg.ConfigFile)), + ), + }, + } +} diff --git a/infrastructure/cronscheduler/gocron.go b/infrastructure/cronscheduler/gocron.go new file mode 100644 index 0000000..072abf6 --- /dev/null +++ b/infrastructure/cronscheduler/gocron.go @@ -0,0 +1,29 @@ +package cronscheduler + +import ( + "time" + + "github.com/labbs/nexo/infrastructure/logger/zerolog" + + "github.com/go-co-op/gocron/v2" + z "github.com/rs/zerolog" +) + +type Config struct { + CronScheduler gocron.Scheduler +} + +// Configure sets up the cron scheduler with the provided logger. +// Will return an error if the scheduler cannot be created (fatal) +func Configure(logger z.Logger) (Config, error) { + logger = logger.With().Str("component", "infrastructure.cronscheduler").Logger() + var cfg Config + s, err := gocron.NewScheduler(gocron.WithLogger(zerolog.GocronAdapter{Logger: logger}), gocron.WithLocation(time.UTC)) + if err != nil { + logger.Fatal().Err(err).Str("event", "cronscheduler.configure.new").Msg("Failed to create cron scheduler") + return cfg, err + } + cfg.CronScheduler = s + s.Start() + return cfg, nil +} diff --git a/infrastructure/database/gorm.go b/infrastructure/database/gorm.go new file mode 100644 index 0000000..85ef942 --- /dev/null +++ b/infrastructure/database/gorm.go @@ -0,0 +1,41 @@ +package database + +import ( + "github.com/labbs/nexo/infrastructure/config" + zerologadapter "github.com/labbs/nexo/infrastructure/logger/zerolog" + + z "github.com/rs/zerolog" + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +type Config struct { + Db *gorm.DB +} + +// Configure sets up the database connection based on the provided configuration and logger. +// It supports sqlite, postgres, and mysql databases. +// Will return an error if the connection cannot be established (fatal) +func Configure(_cfg config.Config, logger z.Logger) (Config, error) { + logger = logger.With().Str("component", "infrastructure.database").Logger() + gormLogger := zerologadapter.NewGormLogger(logger) + + var db *gorm.DB + var err error + + // Check if the database is managed + switch _cfg.Database.Dialect { + case "sqlite": + db, err = gorm.Open(sqlite.Open(_cfg.Database.DSN), &gorm.Config{Logger: gormLogger}) + case "postgres": + db, err = gorm.Open(postgres.Open(_cfg.Database.DSN), &gorm.Config{Logger: gormLogger}) + default: + logger.Fatal().Str("event", "database.configure.invalid_dialect").Msg("Invalid database type") + } + if err != nil { + return Config{}, err + } + + return Config{Db: db}, nil +} diff --git a/infrastructure/deps.go b/infrastructure/deps.go new file mode 100644 index 0000000..3f5a1e8 --- /dev/null +++ b/infrastructure/deps.go @@ -0,0 +1,40 @@ +package infrastructure + +import ( + "github.com/labbs/nexo/application/action" + "github.com/labbs/nexo/application/apikey" + "github.com/labbs/nexo/application/auth" + databaseApp "github.com/labbs/nexo/application/database" + "github.com/labbs/nexo/application/document" + "github.com/labbs/nexo/application/drawing" + "github.com/labbs/nexo/application/group" + "github.com/labbs/nexo/application/session" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/application/user" + "github.com/labbs/nexo/application/webhook" + "github.com/labbs/nexo/infrastructure/config" + "github.com/labbs/nexo/infrastructure/cronscheduler" + "github.com/labbs/nexo/infrastructure/database" + "github.com/labbs/nexo/infrastructure/http" + "github.com/rs/zerolog" +) + +type Deps struct { + Config config.Config + Logger zerolog.Logger + Http http.Config + CronScheduler cronscheduler.Config + Database database.Config + + UserApp *user.UserApp + SessionApp *session.SessionApp + AuthApp *auth.AuthApp + SpaceApp *space.SpaceApp + DocumentApp *document.DocumentApp + ApiKeyApp *apikey.ApiKeyApp + WebhookApp *webhook.WebhookApp + DatabaseApp *databaseApp.DatabaseApp + DrawingApp *drawing.DrawingApp + ActionApp *action.ActionApp + GroupApp *group.GroupApp +} diff --git a/infrastructure/helpers/error/sql_error_catch.go b/infrastructure/helpers/error/sql_error_catch.go new file mode 100644 index 0000000..ed572cf --- /dev/null +++ b/infrastructure/helpers/error/sql_error_catch.go @@ -0,0 +1,48 @@ +package error + +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + +var ( + // Map des patterns génériques vers les erreurs GORM + errorMap = map[string]error{ + "repository.name": gorm.ErrDuplicatedKey, + "repository_name": gorm.ErrDuplicatedKey, + "user.email": gorm.ErrDuplicatedKey, + "user_email": gorm.ErrDuplicatedKey, + "unique constraint failed": gorm.ErrDuplicatedKey, + "duplicate key": gorm.ErrDuplicatedKey, + "duplicate entry": gorm.ErrDuplicatedKey, + "violates unique constraint": gorm.ErrDuplicatedKey, + "foreign key constraint failed": gorm.ErrForeignKeyViolated, + "violates foreign key constraint": gorm.ErrForeignKeyViolated, + "foreign key constraint fails": gorm.ErrForeignKeyViolated, + "check constraint failed": gorm.ErrCheckConstraintViolated, + "violates check constraint": gorm.ErrCheckConstraintViolated, + "check constraint": gorm.ErrCheckConstraintViolated, + } +) + +func Catch(err error) error { + if err == nil { + return nil + } + + if errors.Is(err, gorm.ErrRecordNotFound) { + return gorm.ErrRecordNotFound + } + + errorMsg := strings.ToLower(err.Error()) + + for pattern, gormErr := range errorMap { + if strings.Contains(errorMsg, pattern) { + return gormErr + } + } + + return err +} diff --git a/infrastructure/helpers/mapper/map_struct_by_field_names.go b/infrastructure/helpers/mapper/map_struct_by_field_names.go new file mode 100644 index 0000000..808fe1f --- /dev/null +++ b/infrastructure/helpers/mapper/map_struct_by_field_names.go @@ -0,0 +1,58 @@ +package mapper + +import ( + "reflect" +) + +// MapStructByFieldNames maps fields from source struct to destination struct +// based on matching field names (case sensitive). +// Both src and dst should be pointers to structs. +func MapStructByFieldNames(src interface{}, dst interface{}) error { + srcValue := reflect.ValueOf(src) + dstValue := reflect.ValueOf(dst) + + // Ensure we have pointers + if srcValue.Kind() != reflect.Ptr || dstValue.Kind() != reflect.Ptr { + panic("both src and dst must be pointers") + } + + // Get the underlying structs + srcStruct := srcValue.Elem() + dstStruct := dstValue.Elem() + + // Ensure we have structs + if srcStruct.Kind() != reflect.Struct || dstStruct.Kind() != reflect.Struct { + panic("both src and dst must point to structs") + } + + srcType := srcStruct.Type() + dstType := dstStruct.Type() + + // Create a map of destination field names for quick lookup + dstFields := make(map[string]reflect.Value) + for i := 0; i < dstStruct.NumField(); i++ { + field := dstStruct.Field(i) + fieldName := dstType.Field(i).Name + if field.CanSet() { + dstFields[fieldName] = field + } + } + + // Iterate through source fields and map to destination + for i := 0; i < srcStruct.NumField(); i++ { + srcField := srcStruct.Field(i) + srcFieldName := srcType.Field(i).Name + + // Check if destination has a field with the same name + if dstField, exists := dstFields[srcFieldName]; exists { + // Check if types are compatible + if srcField.Type().AssignableTo(dstField.Type()) { + dstField.Set(srcField) + } else if srcField.Type().ConvertibleTo(dstField.Type()) { + dstField.Set(srcField.Convert(dstField.Type())) + } + } + } + + return nil +} diff --git a/infrastructure/helpers/shortuuid/shortuuid.go b/infrastructure/helpers/shortuuid/shortuuid.go new file mode 100755 index 0000000..6d18153 --- /dev/null +++ b/infrastructure/helpers/shortuuid/shortuuid.go @@ -0,0 +1,22 @@ +package shortuuid + +import ( + "github.com/btcsuite/btcutil/base58" + "github.com/google/uuid" + su "github.com/lithammer/shortuuid/v4" +) + +type base58Encoder struct{} + +func (enc base58Encoder) Encode(u uuid.UUID) string { + return base58.Encode(u[:]) +} + +func (enc base58Encoder) Decode(s string) (uuid.UUID, error) { + return uuid.FromBytes(base58.Decode(s)) +} + +func GenerateShortUUID() string { + enc := base58Encoder{} + return su.NewWithEncoder(enc) +} diff --git a/infrastructure/helpers/tokenutil/structs.go b/infrastructure/helpers/tokenutil/structs.go new file mode 100644 index 0000000..e6d92bd --- /dev/null +++ b/infrastructure/helpers/tokenutil/structs.go @@ -0,0 +1,15 @@ +package tokenutil + +import "github.com/golang-jwt/jwt/v4" + +type JwtCustomClaims struct { + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + jwt.RegisteredClaims +} + +type JwtCustomRefreshClaims struct { + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + jwt.RegisteredClaims +} diff --git a/infrastructure/helpers/tokenutil/tokenutil.go b/infrastructure/helpers/tokenutil/tokenutil.go new file mode 100644 index 0000000..8216a52 --- /dev/null +++ b/infrastructure/helpers/tokenutil/tokenutil.go @@ -0,0 +1,40 @@ +package tokenutil + +import ( + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/labbs/nexo/infrastructure/config" +) + +func CreateAccessToken(user_id, sessionId string, config config.Config) (accessToken string, err error) { + exp := time.Now().Add(time.Minute * time.Duration(config.Session.ExpirationMinutes)).Unix() + claims := &JwtCustomClaims{ + SessionID: sessionId, + UserID: user_id, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: config.Session.Issuer, + ExpiresAt: &jwt.NumericDate{Time: time.Unix(exp, 0)}, + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + t, err := token.SignedString([]byte(config.Session.SecretKey)) + if err != nil { + return "", err + } + return t, nil +} + +func GetSessionIdFromToken(tokenString string, config config.Config) (string, error) { + token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(config.Session.SecretKey), nil + }) + if err != nil { + return "", err + } + if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid { + return claims.SessionID, nil + } else { + return "", err + } +} diff --git a/infrastructure/helpers/validator/is_uuid.go b/infrastructure/helpers/validator/is_uuid.go new file mode 100644 index 0000000..834960b --- /dev/null +++ b/infrastructure/helpers/validator/is_uuid.go @@ -0,0 +1,15 @@ +package validator + +import v "github.com/go-playground/validator/v10" + +func IsValidUUID(uuid string) bool { + validate := v.New() + + type tempStruct struct { + Value string `validate:"uuid4"` + } + + temp := tempStruct{Value: uuid} + err := validate.Struct(temp) + return err == nil +} diff --git a/infrastructure/http/auth_adapter.go b/infrastructure/http/auth_adapter.go new file mode 100644 index 0000000..35199b6 --- /dev/null +++ b/infrastructure/http/auth_adapter.go @@ -0,0 +1,53 @@ +package http + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/session" + "github.com/labbs/nexo/application/session/dto" +) + +// SessionAuthAdapter adapts SessionApp to fiberoapi.AuthorizationService +type SessionAuthAdapter struct { + sessionApp *session.SessionApp +} + +func NewSessionAuthAdapter(sessionApp *session.SessionApp) *SessionAuthAdapter { + return &SessionAuthAdapter{sessionApp: sessionApp} +} + +func (a *SessionAuthAdapter) ValidateToken(token string) (*fiberoapi.AuthContext, error) { + result, err := a.sessionApp.ValidateToken(dto.ValidateTokenInput{Token: token}) + if err != nil { + return nil, err + } + return result.AuthContext, nil +} + +func (a *SessionAuthAdapter) HasRole(ctx *fiberoapi.AuthContext, role string) bool { + return a.sessionApp.HasRole(dto.HasRoleInput{Context: ctx, Role: role}) +} + +func (a *SessionAuthAdapter) HasScope(ctx *fiberoapi.AuthContext, scope string) bool { + return a.sessionApp.HasScope(dto.HasScopeInput{Context: ctx, Scope: scope}) +} + +func (a *SessionAuthAdapter) CanAccessResource(ctx *fiberoapi.AuthContext, resourceType, resourceID, action string) (bool, error) { + return a.sessionApp.CanAccessResource(dto.CanAccessResourceInput{ + Context: ctx, + ResourceType: resourceType, + ResourceID: resourceID, + Action: action, + }) +} + +func (a *SessionAuthAdapter) GetUserPermissions(ctx *fiberoapi.AuthContext, resourceType, resourceID string) (*fiberoapi.ResourcePermission, error) { + result, err := a.sessionApp.GetUserPermissions(dto.GetUserPermissionsInput{ + Context: ctx, + ResourceType: resourceType, + ResourceID: resourceID, + }) + if err != nil { + return nil, err + } + return result.Permission, nil +} diff --git a/infrastructure/http/http.go b/infrastructure/http/http.go new file mode 100644 index 0000000..3ebfc6d --- /dev/null +++ b/infrastructure/http/http.go @@ -0,0 +1,75 @@ +package http + +import ( + "encoding/json" + + "github.com/labbs/nexo/application/session" + "github.com/labbs/nexo/infrastructure/config" + "github.com/labbs/nexo/infrastructure/logger/zerolog" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/fiber/v2/middleware/requestid" + fiberoapi "github.com/labbs/fiber-oapi" + z "github.com/rs/zerolog" +) + +type Config struct { + Fiber *fiber.App + FiberOapi *fiberoapi.OApiApp +} + +// Configure sets up the HTTP server (fiber) with the provided configuration and logger. +// The FiberOapi instance is also configured for OpenAPI support and exposed documentation. +// Will return an error if the server cannot be created (fatal) +func Configure(_cfg config.Config, logger z.Logger, sessionApp *session.SessionApp, enableIU bool) (Config, error) { + var c Config + fiberConfig := fiber.Config{ + JSONEncoder: json.Marshal, + JSONDecoder: json.Unmarshal, + DisableStartupMessage: true, + } + + r := fiber.New(fiberConfig) + + if _cfg.Server.HttpLogs { + r.Use(zerolog.HTTPLogger(logger)) + } + + r.Use(recover.New(recover.Config{ + EnableStackTrace: true, + })) + r.Use(cors.New()) + r.Use(compress.New()) + r.Use(requestid.New()) + + authAdapter := NewSessionAuthAdapter(sessionApp) + + oapiConfig := fiberoapi.Config{ + EnableValidation: true, + EnableOpenAPIDocs: true, + OpenAPIDocsPath: "/documentation", + OpenAPIJSONPath: "/api-spec.json", + OpenAPIYamlPath: "/api-spec.yaml", + AuthService: authAdapter, + EnableAuthorization: true, + SecuritySchemes: map[string]fiberoapi.SecurityScheme{ + "bearerAuth": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "JWT Bearer token", + }, + }, + DefaultSecurity: []map[string][]string{ + {"bearerAuth": {}}, + }, + } + + c.FiberOapi = fiberoapi.New(r, oapiConfig) + c.Fiber = r + + return c, nil +} diff --git a/infrastructure/jobs/clean_users_sessions.go b/infrastructure/jobs/clean_users_sessions.go new file mode 100644 index 0000000..76c6046 --- /dev/null +++ b/infrastructure/jobs/clean_users_sessions.go @@ -0,0 +1,18 @@ +package jobs + +import "github.com/go-co-op/gocron/v2" + +func (c *Config) CleanUsersSessions() error { + logger := c.Logger.With().Str("component", "infrastructure.jobs.clean_users_sessions").Logger() + + _, err := c.CronScheduler.CronScheduler.NewJob( + gocron.CronJob("*/1 * * * * ", false), // Every 1 minute + gocron.NewTask(func() { _ = c.SessionApp.DeleteExpired() }), + gocron.WithName("CleanUsersSessions"), + ) + if err != nil { + logger.Error().Err(err).Msg("failed to schedule CleanUsersSessions job") + } + + return err +} diff --git a/infrastructure/jobs/jobs.go b/infrastructure/jobs/jobs.go new file mode 100644 index 0000000..57f2592 --- /dev/null +++ b/infrastructure/jobs/jobs.go @@ -0,0 +1,24 @@ +package jobs + +import ( + "github.com/labbs/nexo/application/session" + "github.com/labbs/nexo/infrastructure/cronscheduler" + "github.com/rs/zerolog" +) + +type Config struct { + Logger zerolog.Logger + CronScheduler cronscheduler.Config + SessionApp session.SessionApp +} + +func (c *Config) SetupJobs() error { + logger := c.Logger.With().Str("component", "infrastructure.jobs").Logger() + + if err := c.CleanUsersSessions(); err != nil { + logger.Error().Err(err).Msg("failed to setup CleanUsersSessions job") + return err + } + + return nil +} diff --git a/infrastructure/logger/logger.go b/infrastructure/logger/logger.go new file mode 100644 index 0000000..0e79df6 --- /dev/null +++ b/infrastructure/logger/logger.go @@ -0,0 +1,42 @@ +package logger + +import ( + "os" + + "github.com/rs/zerolog" +) + +func NewLogger(level string, pretty bool, version string) zerolog.Logger { + host, err := os.Hostname() + if err != nil { + host = "unknown" + } + + logger := zerolog.New(os.Stderr).With(). + Caller(). + Timestamp(). + Str("host", host). + Str("version", version). + Logger() + + if pretty { + logger = logger.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + } + + switch level { + case "debug": + logger = logger.Level(zerolog.DebugLevel) + case "warn": + logger = logger.Level(zerolog.WarnLevel) + case "error": + logger = logger.Level(zerolog.ErrorLevel) + case "fatal": + logger = logger.Level(zerolog.FatalLevel) + case "panic": + logger = logger.Level(zerolog.PanicLevel) + default: + logger = logger.Level(zerolog.InfoLevel) + } + + return logger +} diff --git a/infrastructure/logger/zerolog/gocron_adapter.go b/infrastructure/logger/zerolog/gocron_adapter.go new file mode 100644 index 0000000..0d221be --- /dev/null +++ b/infrastructure/logger/zerolog/gocron_adapter.go @@ -0,0 +1,27 @@ +package zerolog + +import z "github.com/rs/zerolog" + +type GocronAdapter struct { + Logger z.Logger +} + +func (l GocronAdapter) Println(msg string, v ...any) { + l.Logger.Info().Msgf(msg, v...) +} + +func (l GocronAdapter) Debug(msg string, v ...any) { + l.Logger.Debug().Msgf(msg, v...) +} + +func (l GocronAdapter) Info(msg string, v ...any) { + l.Logger.Info().Msgf(msg, v...) +} + +func (l GocronAdapter) Warn(msg string, v ...any) { + l.Logger.Warn().Msgf(msg, v...) +} + +func (l GocronAdapter) Error(msg string, v ...any) { + l.Logger.Error().Msgf(msg, v...) +} diff --git a/infrastructure/logger/zerolog/goose_adapter.go b/infrastructure/logger/zerolog/goose_adapter.go new file mode 100644 index 0000000..a4514e2 --- /dev/null +++ b/infrastructure/logger/zerolog/goose_adapter.go @@ -0,0 +1,39 @@ +package zerolog + +import ( + "strings" + + "github.com/rs/zerolog" +) + +// ZerologAdapter is a wrapper around zerolog.Logger to implement the goose.Logger interface +type ZerologGooseAdapter struct { + Logger zerolog.Logger +} + +// Print implement the goose.Logger interface method +func (z *ZerologGooseAdapter) Print(args ...any) { + z.Logger.Info().Msgf("%v", args...) +} + +// Printf implement the goose.Logger interface method +func (z *ZerologGooseAdapter) Printf(format string, args ...any) { + f := strings.Replace(format, "\n", "", -1) + z.Logger.Info().Msgf(f, args...) +} + +// Println implement the goose.Logger interface method +func (z *ZerologGooseAdapter) Println(args ...any) { + z.Logger.Info().Msgf("%v", args...) +} + +// Fatal implement the goose.Logger interface method +func (z *ZerologGooseAdapter) Fatal(args ...any) { + z.Logger.Fatal().Msgf("%v", args...) +} + +// Fatalf implement the goose.Logger interface method +func (z *ZerologGooseAdapter) Fatalf(format string, args ...any) { + f := strings.Replace(format, "\n", "", -1) + z.Logger.Fatal().Msgf(f, args...) +} diff --git a/infrastructure/logger/zerolog/gorm_adapter.go b/infrastructure/logger/zerolog/gorm_adapter.go new file mode 100644 index 0000000..5fddc96 --- /dev/null +++ b/infrastructure/logger/zerolog/gorm_adapter.go @@ -0,0 +1,81 @@ +package zerolog + +import ( + "context" + "errors" + "time" + + "github.com/rs/zerolog" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type GormLogger struct { + logger zerolog.Logger + LogLevel gormlogger.LogLevel + SlowThreshold time.Duration +} + +func NewGormLogger(logger zerolog.Logger) *GormLogger { + return &GormLogger{ + logger: logger.With().Str("component", "gorm").Logger(), + LogLevel: gormlogger.Info, + SlowThreshold: 200 * time.Millisecond, + } +} + +func (l *GormLogger) LogMode(level gormlogger.LogLevel) gormlogger.Interface { + newLogger := *l + newLogger.LogLevel = level + return &newLogger +} + +func (l *GormLogger) Info(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Info { + l.logger.Info().Msgf(msg, data...) + } +} + +func (l *GormLogger) Warn(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Warn { + l.logger.Warn().Msgf(msg, data...) + } +} + +func (l *GormLogger) Error(ctx context.Context, msg string, data ...any) { + if l.LogLevel >= gormlogger.Error { + l.logger.Error().Msgf(msg, data...) + } +} + +func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) { + if l.LogLevel <= gormlogger.Silent { + return + } + + elapsed := time.Since(begin) + sql, rows := fc() + + logEvent := l.logger.With(). + Str("type", "sql"). + Float64("elapsed_ms", float64(elapsed.Nanoseconds())/1e6). + Str("sql", sql) + + if rows >= 0 { + logEvent = logEvent.Int64("rows", rows) + } + + logger := logEvent.Logger() + + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logger.Error().Err(err).Send() + return + } + + if l.SlowThreshold != 0 && elapsed > l.SlowThreshold { + logger.Warn().Msg("SLOW SQL") + return + } + + logger.Debug().Send() +} diff --git a/infrastructure/logger/zerolog/http.go b/infrastructure/logger/zerolog/http.go new file mode 100644 index 0000000..6c489c4 --- /dev/null +++ b/infrastructure/logger/zerolog/http.go @@ -0,0 +1,42 @@ +package zerolog + +import ( + "fmt" + "slices" + "time" + + "github.com/gofiber/fiber/v2" + z "github.com/rs/zerolog" +) + +func HTTPLogger(logger z.Logger) fiber.Handler { + return func(c *fiber.Ctx) error { + timeStart := time.Now() + err := c.Next() + var _logger *z.Event + if c.Response().StatusCode() >= 399 { + _logger = logger.Error() + } else { + _logger = logger.Info() + } + + if slices.Contains([]string{"/health", "/metrics", "/favicon.ico"}, c.Path()) { + return err + } + + _logger. + Int("status", c.Response().StatusCode()). + Dur("duration", time.Since(timeStart)). + Str("method", string(c.Request().Header.Method())). + Str("remote_addr", c.IP()). + Str("path", c.Path()). + Str("user_agent", c.Get("User-Agent")). + Int("bytes_sent", c.Response().Header.ContentLength()). + Int("bytes_received", c.Request().Header.ContentLength()). + Str("proto", c.Protocol()). + Str("host", c.Hostname()). + Str("request_id", fmt.Sprintf("%v", c.Locals("requestid"))). + Send() + return err + } +} diff --git a/infrastructure/migration/files/20251003225258_user.go b/infrastructure/migration/files/20251003225258_user.go new file mode 100644 index 0000000..d6b785a --- /dev/null +++ b/infrastructure/migration/files/20251003225258_user.go @@ -0,0 +1,66 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upUser, downUser) +} + +func upUser(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS user ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_url TEXT, + preferences JSON, + active BOOLEAN DEFAULT TRUE, + role TEXT CHECK(role IN ('admin', 'user', 'guest')) DEFAULT 'user', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON user(username); + CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON user(email); + CREATE INDEX IF NOT EXISTS idx_user_active ON user(active); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + email TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar_url TEXT, + preferences JSONB, + active BOOLEAN DEFAULT TRUE, + role TEXT CHECK(role IN ('admin', 'user', 'guest')) DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_users_username ON users(username); + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_active ON users(active); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downUser(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS user;`) + return err +} diff --git a/infrastructure/migration/files/20251003233034_session.go b/infrastructure/migration/files/20251003233034_session.go new file mode 100644 index 0000000..b15140a --- /dev/null +++ b/infrastructure/migration/files/20251003233034_session.go @@ -0,0 +1,58 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upSession, downSession) +} + +func upSession(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS session ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + user_agent TEXT, + ip_address TEXT, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_session_user_id ON session(user_id); + CREATE INDEX IF NOT EXISTS idx_session_expires_at ON session(expires_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS session ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + user_agent TEXT, + ip_address TEXT, + expires_at TIMESTAMPTZ, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_session_user_id ON session(user_id); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_session_expires_at ON session(expires_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downSession(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS session;`) + return err +} diff --git a/infrastructure/migration/files/20251005203951_group.go b/infrastructure/migration/files/20251005203951_group.go new file mode 100644 index 0000000..d3208b1 --- /dev/null +++ b/infrastructure/migration/files/20251005203951_group.go @@ -0,0 +1,62 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upGroup, downGroup) +} + +func upGroup(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS "group" ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + role TEXT NOT NULL, + owner_id TEXT NOT NULL, + members JSONB, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_group_owner_id ON "group"(owner_id); + CREATE INDEX IF NOT EXISTS idx_group_role ON "group"(role); + CREATE UNIQUE INDEX IF NOT EXISTS idx_group_name ON "group"(name); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS "group" ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + description TEXT, + role TEXT NOT NULL, + owner_id UUID NOT NULL, + members JSONB, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_group_owner_id ON "group"(owner_id); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_group_role ON "group"(role); + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_group_name ON "group"(name); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downGroup(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS "group";`) + return err +} diff --git a/infrastructure/migration/files/20251005203956_space.go b/infrastructure/migration/files/20251005203956_space.go new file mode 100644 index 0000000..fb2abf5 --- /dev/null +++ b/infrastructure/migration/files/20251005203956_space.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upSpace, downSpace) +} + +func upSpace(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS space ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + icon TEXT, + icon_color TEXT, + type TEXT CHECK(type IN ('personal', 'public', 'restricted', 'private')) NOT NULL, + owner_id TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP + ); + CREATE INDEX IF NOT EXISTS idx_space_name ON space(name); + CREATE UNIQUE INDEX IF NOT EXISTS idx_space_slug ON space(slug); + CREATE INDEX IF NOT EXISTS idx_space_type ON space(type); + CREATE INDEX IF NOT EXISTS idx_space_deleted_at ON space(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS space ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + icon TEXT, + icon_color TEXT, + type TEXT CHECK(type IN ('personal', 'public', 'restricted', 'private')) NOT NULL, + owner_id UUID, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_space_name ON space(name); + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_space_slug ON space(slug); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_space_type ON space(type); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_space_deleted_at ON space(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downSpace(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS space;`) + return err +} diff --git a/infrastructure/migration/files/20251005204001_document.go b/infrastructure/migration/files/20251005204001_document.go new file mode 100644 index 0000000..71a8bcc --- /dev/null +++ b/infrastructure/migration/files/20251005204001_document.go @@ -0,0 +1,76 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDocument, downDocument) +} + +func upDocument(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS document ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + config JSONB, + metadata JSONB, + parent_id TEXT, + space_id TEXT NOT NULL, + public BOOLEAN DEFAULT FALSE, + content JSONB DEFAULT '[]', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES space(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES document(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_document_space_id ON document(space_id); + CREATE INDEX IF NOT EXISTS idx_document_parent_id ON document(parent_id); + CREATE INDEX IF NOT EXISTS idx_document_public ON document(public); + CREATE INDEX IF NOT EXISTS idx_document_deleted_at ON document(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS document ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + config JSONB, + metadata JSONB, + parent_id UUID, + space_id UUID NOT NULL, + public BOOLEAN DEFAULT FALSE, + content JSONB DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + FOREIGN KEY (space_id) REFERENCES space(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES document(id) ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS idx_document_space_id ON document(space_id); + CREATE INDEX IF NOT EXISTS idx_document_parent_id ON document(parent_id); + CREATE INDEX IF NOT EXISTS idx_document_public ON document(public); + CREATE INDEX IF NOT EXISTS idx_document_deleted_at ON document(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downDocument(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS document;`) + return err +} diff --git a/infrastructure/migration/files/20251005204011_favorite.go b/infrastructure/migration/files/20251005204011_favorite.go new file mode 100644 index 0000000..3dd16f5 --- /dev/null +++ b/infrastructure/migration/files/20251005204011_favorite.go @@ -0,0 +1,60 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upFavorite, downFavorite) +} + +func upFavorite(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS favorite ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + document_id TEXT NOT NULL, + space_id TEXT NOT NULL, + position INTEGER NOT NULL, + created_at TIMESTAMP NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_favorite_user_id ON favorite(user_id); + CREATE INDEX IF NOT EXISTS idx_favorite_document_id ON favorite(document_id); + CREATE INDEX IF NOT EXISTS idx_favorite_space_id ON favorite(space_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_favorite_user_document ON favorite(user_id, document_id, space_id); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS favorite ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL, + document_id UUID NOT NULL, + space_id UUID NOT NULL, + position INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_favorite_user_id ON favorite(user_id); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_favorite_document_id ON favorite(document_id); + CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_favorite_space_id ON favorite(space_id); + CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS idx_favorite_user_document ON favorite(user_id, document_id, space_id); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downFavorite(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS favorite;`) + return err +} diff --git a/infrastructure/migration/files/20251021224710_permission.go b/infrastructure/migration/files/20251021224710_permission.go new file mode 100644 index 0000000..2bee8f5 --- /dev/null +++ b/infrastructure/migration/files/20251021224710_permission.go @@ -0,0 +1,92 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upPermission, downPermission) +} + +func upPermission(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS permission ( + id TEXT PRIMARY KEY, + type TEXT CHECK(type IN ('space', 'document', 'database', 'drawing')) NOT NULL, + space_id TEXT, + document_id TEXT, + database_id TEXT, + drawing_id TEXT, + user_id TEXT, + group_id TEXT, + role TEXT CHECK(role IN ('owner', 'admin', 'editor', 'viewer', 'denied')) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES space(id) ON DELETE CASCADE, + FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE, + FOREIGN KEY (database_id) REFERENCES database(id) ON DELETE CASCADE, + FOREIGN KEY (drawing_id) REFERENCES drawing(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE, + FOREIGN KEY (group_id) REFERENCES "group"(id) ON DELETE CASCADE, + CHECK((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)) + ); + CREATE INDEX IF NOT EXISTS idx_permission_type ON permission(type); + CREATE INDEX IF NOT EXISTS idx_permission_space_id ON permission(space_id) WHERE space_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_document_id ON permission(document_id) WHERE document_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_database_id ON permission(database_id) WHERE database_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_drawing_id ON permission(drawing_id) WHERE drawing_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_user_id ON permission(user_id) WHERE user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_group_id ON permission(group_id) WHERE group_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_deleted_at ON permission(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_permission_resource_user ON permission(type, space_id, document_id, database_id, drawing_id, user_id) WHERE user_id IS NOT NULL AND deleted_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_permission_resource_group ON permission(type, space_id, document_id, database_id, drawing_id, group_id) WHERE group_id IS NOT NULL AND deleted_at IS NULL; + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS permission ( + id UUID PRIMARY KEY, + type TEXT CHECK(type IN ('space', 'document', 'database', 'drawing')) NOT NULL, + space_id UUID REFERENCES space(id) ON DELETE CASCADE, + document_id UUID REFERENCES document(id) ON DELETE CASCADE, + database_id UUID REFERENCES database(id) ON DELETE CASCADE, + drawing_id UUID REFERENCES drawing(id) ON DELETE CASCADE, + user_id UUID REFERENCES "user"(id) ON DELETE CASCADE, + group_id UUID REFERENCES "group"(id) ON DELETE CASCADE, + role TEXT CHECK(role IN ('owner', 'admin', 'editor', 'viewer', 'denied')) NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ, + CHECK((user_id IS NOT NULL AND group_id IS NULL) OR (user_id IS NULL AND group_id IS NOT NULL)) + ); + CREATE INDEX IF NOT EXISTS idx_permission_type ON permission(type); + CREATE INDEX IF NOT EXISTS idx_permission_space_id ON permission(space_id) WHERE space_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_document_id ON permission(document_id) WHERE document_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_database_id ON permission(database_id) WHERE database_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_drawing_id ON permission(drawing_id) WHERE drawing_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_user_id ON permission(user_id) WHERE user_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_group_id ON permission(group_id) WHERE group_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_permission_deleted_at ON permission(deleted_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_permission_resource_user ON permission(type, COALESCE(space_id, '00000000-0000-0000-0000-000000000000'), COALESCE(document_id, '00000000-0000-0000-0000-000000000000'), COALESCE(database_id, '00000000-0000-0000-0000-000000000000'), COALESCE(drawing_id, '00000000-0000-0000-0000-000000000000'), user_id) WHERE user_id IS NOT NULL AND deleted_at IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS idx_permission_resource_group ON permission(type, COALESCE(space_id, '00000000-0000-0000-0000-000000000000'), COALESCE(document_id, '00000000-0000-0000-0000-000000000000'), COALESCE(database_id, '00000000-0000-0000-0000-000000000000'), COALESCE(drawing_id, '00000000-0000-0000-0000-000000000000'), group_id) WHERE group_id IS NOT NULL AND deleted_at IS NULL; + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downPermission(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS permission;`) + return err +} diff --git a/infrastructure/migration/files/20251231120000_comment.go b/infrastructure/migration/files/20251231120000_comment.go new file mode 100644 index 0000000..0e24b3d --- /dev/null +++ b/infrastructure/migration/files/20251231120000_comment.go @@ -0,0 +1,73 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upComment, downComment) +} + +func upComment(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS comment ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + user_id TEXT NOT NULL, + parent_id TEXT, + content TEXT NOT NULL, + block_id TEXT, + resolved INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (document_id) REFERENCES document(id), + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (parent_id) REFERENCES comment(id) + ); + CREATE INDEX IF NOT EXISTS idx_comment_document_id ON comment(document_id); + CREATE INDEX IF NOT EXISTS idx_comment_user_id ON comment(user_id); + CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id); + CREATE INDEX IF NOT EXISTS idx_comment_block_id ON comment(block_id); + CREATE INDEX IF NOT EXISTS idx_comment_deleted_at ON comment(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS comment ( + id UUID PRIMARY KEY, + document_id UUID NOT NULL REFERENCES document(id), + user_id UUID NOT NULL REFERENCES "user"(id), + parent_id UUID REFERENCES comment(id), + content TEXT NOT NULL, + block_id TEXT, + resolved BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_comment_document_id ON comment(document_id); + CREATE INDEX IF NOT EXISTS idx_comment_user_id ON comment(user_id); + CREATE INDEX IF NOT EXISTS idx_comment_parent_id ON comment(parent_id); + CREATE INDEX IF NOT EXISTS idx_comment_block_id ON comment(block_id); + CREATE INDEX IF NOT EXISTS idx_comment_deleted_at ON comment(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downComment(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS comment;`) + return err +} diff --git a/infrastructure/migration/files/20251231130000_document_version.go b/infrastructure/migration/files/20251231130000_document_version.go new file mode 100644 index 0000000..dc44cd9 --- /dev/null +++ b/infrastructure/migration/files/20251231130000_document_version.go @@ -0,0 +1,66 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDocumentVersion, downDocumentVersion) +} + +func upDocumentVersion(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS document_version ( + id TEXT PRIMARY KEY, + document_id TEXT NOT NULL, + user_id TEXT NOT NULL, + version INTEGER NOT NULL, + name TEXT NOT NULL, + content TEXT, + config TEXT, + description TEXT, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES user(id) + ); + CREATE INDEX IF NOT EXISTS idx_document_version_document_id ON document_version(document_id); + CREATE INDEX IF NOT EXISTS idx_document_version_created_at ON document_version(created_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_version_doc_version ON document_version(document_id, version); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS document_version ( + id UUID PRIMARY KEY, + document_id UUID NOT NULL REFERENCES document(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES "user"(id), + version INTEGER NOT NULL, + name TEXT NOT NULL, + content JSONB, + config JSONB, + description TEXT, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_document_version_document_id ON document_version(document_id); + CREATE INDEX IF NOT EXISTS idx_document_version_created_at ON document_version(created_at); + CREATE UNIQUE INDEX IF NOT EXISTS idx_document_version_doc_version ON document_version(document_id, version); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downDocumentVersion(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS document_version;`) + return err +} diff --git a/infrastructure/migration/files/20251231140000_api_key.go b/infrastructure/migration/files/20251231140000_api_key.go new file mode 100644 index 0000000..adff2ed --- /dev/null +++ b/infrastructure/migration/files/20251231140000_api_key.go @@ -0,0 +1,69 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upApiKey, downApiKey) +} + +func upApiKey(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS api_key ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + permissions TEXT, + last_used_at TIMESTAMP, + expires_at TIMESTAMP, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id) + ); + CREATE INDEX IF NOT EXISTS idx_api_key_user_id ON api_key(user_id); + CREATE INDEX IF NOT EXISTS idx_api_key_key_hash ON api_key(key_hash); + CREATE INDEX IF NOT EXISTS idx_api_key_deleted_at ON api_key(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS api_key ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES "user"(id), + name TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + key_prefix TEXT NOT NULL, + permissions JSONB, + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_api_key_user_id ON api_key(user_id); + CREATE INDEX IF NOT EXISTS idx_api_key_key_hash ON api_key(key_hash); + CREATE INDEX IF NOT EXISTS idx_api_key_deleted_at ON api_key(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downApiKey(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS api_key;`) + return err +} diff --git a/infrastructure/migration/files/20251231150000_webhook.go b/infrastructure/migration/files/20251231150000_webhook.go new file mode 100644 index 0000000..2655e73 --- /dev/null +++ b/infrastructure/migration/files/20251231150000_webhook.go @@ -0,0 +1,112 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upWebhook, downWebhook) +} + +func upWebhook(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS webhook ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + space_id TEXT, + name TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events TEXT, + active INTEGER DEFAULT 1, + last_error TEXT, + last_error_at TIMESTAMP, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (space_id) REFERENCES space(id) + ); + CREATE INDEX IF NOT EXISTS idx_webhook_user_id ON webhook(user_id); + CREATE INDEX IF NOT EXISTS idx_webhook_space_id ON webhook(space_id); + CREATE INDEX IF NOT EXISTS idx_webhook_active ON webhook(active); + CREATE INDEX IF NOT EXISTS idx_webhook_deleted_at ON webhook(deleted_at); + + CREATE TABLE IF NOT EXISTS webhook_delivery ( + id TEXT PRIMARY KEY, + webhook_id TEXT NOT NULL, + event TEXT NOT NULL, + payload TEXT, + status_code INTEGER, + response TEXT, + duration INTEGER, + success INTEGER, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (webhook_id) REFERENCES webhook(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_webhook_delivery_webhook_id ON webhook_delivery(webhook_id); + CREATE INDEX IF NOT EXISTS idx_webhook_delivery_created_at ON webhook_delivery(created_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS webhook ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES "user"(id), + space_id UUID REFERENCES space(id), + name TEXT NOT NULL, + url TEXT NOT NULL, + secret TEXT NOT NULL, + events JSONB, + active BOOLEAN DEFAULT TRUE, + last_error TEXT, + last_error_at TIMESTAMPTZ, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_webhook_user_id ON webhook(user_id); + CREATE INDEX IF NOT EXISTS idx_webhook_space_id ON webhook(space_id); + CREATE INDEX IF NOT EXISTS idx_webhook_active ON webhook(active); + CREATE INDEX IF NOT EXISTS idx_webhook_deleted_at ON webhook(deleted_at); + + CREATE TABLE IF NOT EXISTS webhook_delivery ( + id UUID PRIMARY KEY, + webhook_id UUID NOT NULL REFERENCES webhook(id) ON DELETE CASCADE, + event TEXT NOT NULL, + payload JSONB, + status_code INTEGER, + response TEXT, + duration INTEGER, + success BOOLEAN, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_webhook_delivery_webhook_id ON webhook_delivery(webhook_id); + CREATE INDEX IF NOT EXISTS idx_webhook_delivery_created_at ON webhook_delivery(created_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downWebhook(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + DROP TABLE IF EXISTS webhook_delivery; + DROP TABLE IF EXISTS webhook; + `) + return err +} diff --git a/infrastructure/migration/files/20251231160000_database.go b/infrastructure/migration/files/20251231160000_database.go new file mode 100644 index 0000000..e7e58e2 --- /dev/null +++ b/infrastructure/migration/files/20251231160000_database.go @@ -0,0 +1,113 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDatabase, downDatabase) +} + +func upDatabase(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS database ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + document_id TEXT, + name TEXT NOT NULL, + description TEXT, + icon TEXT, + schema TEXT, + views TEXT, + default_view TEXT DEFAULT 'table', + type TEXT DEFAULT 'spreadsheet', + created_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES space(id), + FOREIGN KEY (document_id) REFERENCES document(id), + FOREIGN KEY (created_by) REFERENCES user(id) + ); + CREATE INDEX IF NOT EXISTS idx_database_space_id ON database(space_id); + CREATE INDEX IF NOT EXISTS idx_database_document_id ON database(document_id); + CREATE INDEX IF NOT EXISTS idx_database_deleted_at ON database(deleted_at); + + CREATE TABLE IF NOT EXISTS database_row ( + id TEXT PRIMARY KEY, + database_id TEXT NOT NULL, + properties TEXT, + content TEXT, + show_in_sidebar INTEGER DEFAULT 0, + created_by TEXT NOT NULL, + updated_by TEXT, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (database_id) REFERENCES database(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES user(id), + FOREIGN KEY (updated_by) REFERENCES user(id) + ); + CREATE INDEX IF NOT EXISTS idx_database_row_database_id ON database_row(database_id); + CREATE INDEX IF NOT EXISTS idx_database_row_deleted_at ON database_row(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS database ( + id UUID PRIMARY KEY, + space_id UUID NOT NULL REFERENCES space(id), + document_id UUID REFERENCES document(id), + name TEXT NOT NULL, + description TEXT, + icon TEXT, + schema JSONB, + views JSONB, + default_view TEXT DEFAULT 'table', + type TEXT DEFAULT 'spreadsheet', + created_by UUID NOT NULL REFERENCES "user"(id), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_database_space_id ON database(space_id); + CREATE INDEX IF NOT EXISTS idx_database_document_id ON database(document_id); + CREATE INDEX IF NOT EXISTS idx_database_deleted_at ON database(deleted_at); + + CREATE TABLE IF NOT EXISTS database_row ( + id UUID PRIMARY KEY, + database_id UUID NOT NULL REFERENCES database(id) ON DELETE CASCADE, + properties JSONB, + content JSONB, + show_in_sidebar BOOLEAN DEFAULT false, + created_by UUID NOT NULL REFERENCES "user"(id), + updated_by UUID REFERENCES "user"(id), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_database_row_database_id ON database_row(database_id); + CREATE INDEX IF NOT EXISTS idx_database_row_deleted_at ON database_row(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downDatabase(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + DROP TABLE IF EXISTS database_row; + DROP TABLE IF EXISTS database; + `) + return err +} diff --git a/infrastructure/migration/files/20251231170000_action.go b/infrastructure/migration/files/20251231170000_action.go new file mode 100644 index 0000000..df37b98 --- /dev/null +++ b/infrastructure/migration/files/20251231170000_action.go @@ -0,0 +1,120 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAction, downAction) +} + +func upAction(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS action ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + space_id TEXT, + database_id TEXT, + name TEXT NOT NULL, + description TEXT, + trigger_type TEXT NOT NULL, + trigger_config TEXT, + steps TEXT, + active INTEGER DEFAULT 1, + last_run_at TIMESTAMP, + last_error TEXT, + run_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES user(id), + FOREIGN KEY (space_id) REFERENCES space(id) + ); + CREATE INDEX IF NOT EXISTS idx_action_user_id ON action(user_id); + CREATE INDEX IF NOT EXISTS idx_action_space_id ON action(space_id); + CREATE INDEX IF NOT EXISTS idx_action_database_id ON action(database_id); + CREATE INDEX IF NOT EXISTS idx_action_trigger_type ON action(trigger_type); + CREATE INDEX IF NOT EXISTS idx_action_active ON action(active); + CREATE INDEX IF NOT EXISTS idx_action_deleted_at ON action(deleted_at); + + CREATE TABLE IF NOT EXISTS action_run ( + id TEXT PRIMARY KEY, + action_id TEXT NOT NULL, + trigger_data TEXT, + steps_result TEXT, + success INTEGER, + error TEXT, + duration INTEGER, + created_at TIMESTAMP NOT NULL, + FOREIGN KEY (action_id) REFERENCES action(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_action_run_action_id ON action_run(action_id); + CREATE INDEX IF NOT EXISTS idx_action_run_created_at ON action_run(created_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS action ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES "user"(id), + space_id UUID REFERENCES space(id), + database_id UUID, + name TEXT NOT NULL, + description TEXT, + trigger_type TEXT NOT NULL, + trigger_config JSONB, + steps JSONB, + active BOOLEAN DEFAULT TRUE, + last_run_at TIMESTAMPTZ, + last_error TEXT, + run_count INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + failure_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_action_user_id ON action(user_id); + CREATE INDEX IF NOT EXISTS idx_action_space_id ON action(space_id); + CREATE INDEX IF NOT EXISTS idx_action_database_id ON action(database_id); + CREATE INDEX IF NOT EXISTS idx_action_trigger_type ON action(trigger_type); + CREATE INDEX IF NOT EXISTS idx_action_active ON action(active); + CREATE INDEX IF NOT EXISTS idx_action_deleted_at ON action(deleted_at); + + CREATE TABLE IF NOT EXISTS action_run ( + id UUID PRIMARY KEY, + action_id UUID NOT NULL REFERENCES action(id) ON DELETE CASCADE, + trigger_data JSONB, + steps_result JSONB, + success BOOLEAN, + error TEXT, + duration INTEGER, + created_at TIMESTAMPTZ NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_action_run_action_id ON action_run(action_id); + CREATE INDEX IF NOT EXISTS idx_action_run_created_at ON action_run(created_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downAction(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, ` + DROP TABLE IF EXISTS action_run; + DROP TABLE IF EXISTS action; + `) + return err +} diff --git a/infrastructure/migration/files/20260104163227_group_members.go b/infrastructure/migration/files/20260104163227_group_members.go new file mode 100644 index 0000000..63e948a --- /dev/null +++ b/infrastructure/migration/files/20260104163227_group_members.go @@ -0,0 +1,68 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upGroupMembers, downGroupMembers) +} + +func upGroupMembers(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + -- Add deleted_at column to group table + ALTER TABLE "group" ADD COLUMN deleted_at TIMESTAMP; + + -- Create group_members join table + CREATE TABLE IF NOT EXISTS group_members ( + group_id TEXT NOT NULL, + user_id TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES "group"(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_group_members_group_id ON group_members(group_id); + CREATE INDEX IF NOT EXISTS idx_group_members_user_id ON group_members(user_id); + ` + case "postgres": + query = ` + -- Add deleted_at column to group table + ALTER TABLE "group" ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; + + -- Create group_members join table + CREATE TABLE IF NOT EXISTS group_members ( + group_id UUID NOT NULL, + user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (group_id, user_id), + FOREIGN KEY (group_id) REFERENCES "group"(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES "user"(id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS idx_group_members_group_id ON group_members(group_id); + CREATE INDEX IF NOT EXISTS idx_group_members_user_id ON group_members(user_id); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downGroupMembers(ctx context.Context, tx *sql.Tx) error { + query := ` + DROP TABLE IF EXISTS group_members; + ALTER TABLE "group" DROP COLUMN IF EXISTS deleted_at; + ` + _, err := tx.ExecContext(ctx, query) + return err +} diff --git a/infrastructure/migration/files/20260106182110_admin_user_group.go b/infrastructure/migration/files/20260106182110_admin_user_group.go new file mode 100644 index 0000000..feeb318 --- /dev/null +++ b/infrastructure/migration/files/20260106182110_admin_user_group.go @@ -0,0 +1,134 @@ +package migrations + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "fmt" + + "github.com/google/uuid" + "github.com/pressly/goose/v3" + "github.com/rs/zerolog" + "golang.org/x/crypto/bcrypt" +) + +func init() { + goose.AddMigrationContext(upAdminUser, downAdminUser) +} + +func generateRandomPassword(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.URLEncoding.EncodeToString(bytes)[:length], nil +} + +func upAdminUser(ctx context.Context, tx *sql.Tx) error { + // Generate UUIDs for admin user and group + adminUserId := uuid.New().String() + adminGroupId := uuid.New().String() + + // Generate random password + randomPassword, err := generateRandomPassword(16) + if err != nil { + return fmt.Errorf("failed to generate random password: %w", err) + } + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(randomPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Log the generated password + logger, _ := ctx.Value("logger").(zerolog.Logger) + logger.Info().Str("username", "admin").Str("email", "admin@nexo.local").Str("password", randomPassword).Msg("Admin user created") + + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + // Insert admin user + _, err = tx.ExecContext(ctx, ` + INSERT INTO user (id, username, email, password, role, active, created_at, updated_at) + VALUES (?, 'admin', 'admin@nexo.local', ?, 'admin', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + `, adminUserId, string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + // Insert admin group + _, err = tx.ExecContext(ctx, ` + INSERT INTO "group" (id, name, description, role, owner_id, created_at, updated_at) + VALUES (?, 'Administrators', 'Default administrator group', 'admin', ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP); + `, adminGroupId, adminUserId) + if err != nil { + return fmt.Errorf("failed to create admin group: %w", err) + } + + // Add admin user to admin group + _, err = tx.ExecContext(ctx, ` + INSERT INTO group_members (group_id, user_id, created_at) + VALUES (?, ?, CURRENT_TIMESTAMP); + `, adminGroupId, adminUserId) + if err != nil { + return fmt.Errorf("failed to add admin user to admin group: %w", err) + } + + case "postgres": + // Insert admin user + _, err = tx.ExecContext(ctx, ` + INSERT INTO "user" (id, username, email, password, role, active, created_at, updated_at) + VALUES ($1, 'admin', 'admin@nexo.local', $2, 'admin', true, NOW(), NOW()); + `, adminUserId, string(hashedPassword)) + if err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + // Insert admin group + _, err = tx.ExecContext(ctx, ` + INSERT INTO "group" (id, name, description, role, owner_id, created_at, updated_at) + VALUES ($1, 'Administrators', 'Default administrator group', 'admin', $2, NOW(), NOW()); + `, adminGroupId, adminUserId) + if err != nil { + return fmt.Errorf("failed to create admin group: %w", err) + } + + // Add admin user to admin group + _, err = tx.ExecContext(ctx, ` + INSERT INTO group_members (group_id, user_id, created_at) + VALUES ($1, $2, NOW()); + `, adminGroupId, adminUserId) + if err != nil { + return fmt.Errorf("failed to add admin user to admin group: %w", err) + } + + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + return nil +} + +func downAdminUser(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + _, err := tx.ExecContext(ctx, ` + DELETE FROM group_members WHERE group_id IN (SELECT id FROM "group" WHERE name = 'Administrators'); + DELETE FROM "group" WHERE name = 'Administrators'; + DELETE FROM user WHERE username = 'admin'; + `) + return err + case "postgres": + _, err := tx.ExecContext(ctx, ` + DELETE FROM group_members WHERE group_id IN (SELECT id FROM "group" WHERE name = 'Administrators'); + DELETE FROM "group" WHERE name = 'Administrators'; + DELETE FROM "user" WHERE username = 'admin'; + `) + return err + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } +} diff --git a/infrastructure/migration/files/20260118180000_drawing.go b/infrastructure/migration/files/20260118180000_drawing.go new file mode 100644 index 0000000..d551951 --- /dev/null +++ b/infrastructure/migration/files/20260118180000_drawing.go @@ -0,0 +1,75 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upDrawing, downDrawing) +} + +func upDrawing(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + CREATE TABLE IF NOT EXISTS drawing ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL, + document_id TEXT, + name TEXT NOT NULL, + icon TEXT DEFAULT '', + elements TEXT DEFAULT '[]', + app_state TEXT DEFAULT '{}', + files TEXT DEFAULT '{}', + thumbnail TEXT, + created_by TEXT NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + deleted_at TIMESTAMP, + FOREIGN KEY (space_id) REFERENCES space(id) ON DELETE CASCADE, + FOREIGN KEY (document_id) REFERENCES document(id) ON DELETE SET NULL, + FOREIGN KEY (created_by) REFERENCES user(id) + ); + CREATE INDEX IF NOT EXISTS idx_drawing_space_id ON drawing(space_id); + CREATE INDEX IF NOT EXISTS idx_drawing_document_id ON drawing(document_id); + CREATE INDEX IF NOT EXISTS idx_drawing_deleted_at ON drawing(deleted_at); + ` + case "postgres": + query = ` + CREATE TABLE IF NOT EXISTS drawing ( + id UUID PRIMARY KEY, + space_id UUID NOT NULL REFERENCES space(id) ON DELETE CASCADE, + document_id UUID REFERENCES document(id) ON DELETE SET NULL, + name TEXT NOT NULL, + icon TEXT DEFAULT '', + elements JSONB DEFAULT '[]', + app_state JSONB DEFAULT '{}', + files JSONB DEFAULT '{}', + thumbnail TEXT, + created_by UUID NOT NULL REFERENCES "user"(id), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + deleted_at TIMESTAMPTZ + ); + CREATE INDEX IF NOT EXISTS idx_drawing_space_id ON drawing(space_id); + CREATE INDEX IF NOT EXISTS idx_drawing_document_id ON drawing(document_id); + CREATE INDEX IF NOT EXISTS idx_drawing_deleted_at ON drawing(deleted_at); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + return err +} + +func downDrawing(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `DROP TABLE IF EXISTS drawing;`) + return err +} diff --git a/infrastructure/migration/files/20260127200000_add_position.go b/infrastructure/migration/files/20260127200000_add_position.go new file mode 100644 index 0000000..13edef3 --- /dev/null +++ b/infrastructure/migration/files/20260127200000_add_position.go @@ -0,0 +1,107 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddPosition, downAddPosition) +} + +func upAddPosition(ctx context.Context, tx *sql.Tx) error { + var query string + dialect, _ := ctx.Value("dbDialect").(string) + switch dialect { + case "sqlite": + query = ` + ALTER TABLE document ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_document_position ON document(position); + + ALTER TABLE database ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_database_position ON database(position); + + ALTER TABLE drawing ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_drawing_position ON drawing(position); + ` + case "postgres": + query = ` + ALTER TABLE document ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_document_position ON document(position); + + ALTER TABLE "database" ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_database_position ON "database"(position); + + ALTER TABLE drawing ADD COLUMN position INTEGER DEFAULT 0; + CREATE INDEX IF NOT EXISTS idx_drawing_position ON drawing(position); + ` + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + _, err := tx.ExecContext(ctx, query) + if err != nil { + return err + } + + // Initialize positions for existing documents based on created_at order + // Group by parent_id + space_id to set position within each sibling group + initQueries := []string{ + `UPDATE document SET position = sub.rn FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY COALESCE(parent_id, ''), space_id ORDER BY created_at ASC) - 1 AS rn + FROM document WHERE deleted_at IS NULL + ) sub WHERE document.id = sub.id`, + `UPDATE "database" SET position = sub.rn FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY COALESCE(document_id, ''), space_id ORDER BY created_at ASC) - 1 AS rn + FROM "database" WHERE deleted_at IS NULL + ) sub WHERE "database".id = sub.id`, + `UPDATE drawing SET position = sub.rn FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY COALESCE(document_id, ''), space_id ORDER BY created_at ASC) - 1 AS rn + FROM drawing WHERE deleted_at IS NULL + ) sub WHERE drawing.id = sub.id`, + } + + if dialect == "sqlite" { + // SQLite doesn't support UPDATE ... FROM with window functions + // Use a simpler approach: positions will be 0 for all existing records (default) + // This is acceptable as existing records had no explicit ordering + return nil + } + + for _, q := range initQueries { + if _, err := tx.ExecContext(ctx, q); err != nil { + return err + } + } + + return nil +} + +func downAddPosition(ctx context.Context, tx *sql.Tx) error { + dialect, _ := ctx.Value("dbDialect").(string) + var queries []string + switch dialect { + case "sqlite": + // SQLite doesn't support DROP COLUMN before 3.35.0 + // We'll just leave the columns as-is for down migration safety + return nil + case "postgres": + queries = []string{ + `ALTER TABLE document DROP COLUMN IF EXISTS position`, + `ALTER TABLE "database" DROP COLUMN IF EXISTS position`, + `ALTER TABLE drawing DROP COLUMN IF EXISTS position`, + } + default: + return fmt.Errorf("unsupported dialect: %s", dialect) + } + + for _, q := range queries { + if _, err := tx.ExecContext(ctx, q); err != nil { + return err + } + } + return nil +} diff --git a/infrastructure/migration/migration.go b/infrastructure/migration/migration.go new file mode 100644 index 0000000..9fee09e --- /dev/null +++ b/infrastructure/migration/migration.go @@ -0,0 +1,49 @@ +package migration + +import ( + "context" + "embed" + + "github.com/labbs/nexo/infrastructure/logger/zerolog" + + _ "github.com/labbs/nexo/infrastructure/migration/files" + + "github.com/pressly/goose/v3" + z "github.com/rs/zerolog" + "gorm.io/gorm" +) + +//go:embed files/* +var migrationFiles embed.FS + +func RunMigration(l z.Logger, db *gorm.DB) error { + logger := l.With().Str("component", "infrastructure.migration").Logger() + goose.SetBaseFS(migrationFiles) + goose.SetLogger(&zerolog.ZerologGooseAdapter{Logger: logger}) + + // Set the dialect following the gorm dialect + dbDialect := db.Dialector.Name() + + if err := goose.SetDialect(dbDialect); err != nil { + logger.Error().Err(err).Str("event", "migration.failed_to_set_dialect").Msg("Failed to set dialect") + return err + } + + sqlDB, err := db.DB() + if err != nil { + logger.Error().Err(err).Str("event", "migration.failed_to_get_sql_db").Msg("Failed to get sql db") + return err + } + + ctx := context.WithValue(context.Background(), "dbDialect", dbDialect) + ctx = context.WithValue(ctx, "logger", logger) + + if err := goose.UpContext(ctx, sqlDB, "files"); err != nil { + if err.Error() != "no change" { + logger.Error().Err(err).Str("event", "migration.failed_to_run_migrations").Msg("Failed to run migrations") + return err + } + } + + return nil +} diff --git a/infrastructure/persistence/action_pers.go b/infrastructure/persistence/action_pers.go new file mode 100644 index 0000000..41df8e5 --- /dev/null +++ b/infrastructure/persistence/action_pers.go @@ -0,0 +1,126 @@ +package persistence + +import ( + "time" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type actionPers struct { + db *gorm.DB +} + +func NewActionPers(db *gorm.DB) *actionPers { + return &actionPers{db: db} +} + +func (p *actionPers) Create(action *domain.Action) error { + return p.db.Create(action).Error +} + +func (p *actionPers) GetById(id string) (*domain.Action, error) { + var action domain.Action + err := p.db. + Preload("User"). + Preload("Space"). + Where("id = ?", id). + First(&action).Error + if err != nil { + return nil, err + } + return &action, nil +} + +func (p *actionPers) GetByUserId(userId string) ([]domain.Action, error) { + var actions []domain.Action + err := p.db. + Preload("Space"). + Where("user_id = ?", userId). + Order("created_at DESC"). + Find(&actions).Error + if err != nil { + return nil, err + } + return actions, nil +} + +func (p *actionPers) GetActiveByTrigger(triggerType domain.ActionTriggerType, spaceId *string, databaseId *string) ([]domain.Action, error) { + var actions []domain.Action + query := p.db.Where("active = ? AND trigger_type = ?", true, triggerType) + + if spaceId != nil { + query = query.Where("space_id = ? OR space_id IS NULL", *spaceId) + } + + if databaseId != nil { + query = query.Where("database_id = ? OR database_id IS NULL", *databaseId) + } + + err := query.Find(&actions).Error + if err != nil { + return nil, err + } + return actions, nil +} + +func (p *actionPers) Update(action *domain.Action) error { + return p.db.Save(action).Error +} + +func (p *actionPers) Delete(id string) error { + return p.db.Where("id = ?", id).Delete(&domain.Action{}).Error +} + +func (p *actionPers) IncrementSuccess(id string) error { + return p.db.Model(&domain.Action{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "success_count": gorm.Expr("success_count + 1"), + "run_count": gorm.Expr("run_count + 1"), + "last_error": "", + }).Error +} + +func (p *actionPers) RecordFailure(id string, errorMsg string) error { + return p.db.Model(&domain.Action{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "failure_count": gorm.Expr("failure_count + 1"), + "run_count": gorm.Expr("run_count + 1"), + "last_error": errorMsg, + }).Error +} + +func (p *actionPers) UpdateLastRun(id string) error { + now := time.Now() + return p.db.Model(&domain.Action{}). + Where("id = ?", id). + Update("last_run_at", &now).Error +} + +// ActionRun persistence +type actionRunPers struct { + db *gorm.DB +} + +func NewActionRunPers(db *gorm.DB) *actionRunPers { + return &actionRunPers{db: db} +} + +func (p *actionRunPers) Create(run *domain.ActionRun) error { + return p.db.Create(run).Error +} + +func (p *actionRunPers) GetByActionId(actionId string, limit int) ([]domain.ActionRun, error) { + var runs []domain.ActionRun + err := p.db. + Where("action_id = ?", actionId). + Order("created_at DESC"). + Limit(limit). + Find(&runs).Error + if err != nil { + return nil, err + } + return runs, nil +} diff --git a/infrastructure/persistence/api_key_pers.go b/infrastructure/persistence/api_key_pers.go new file mode 100644 index 0000000..d8ead38 --- /dev/null +++ b/infrastructure/persistence/api_key_pers.go @@ -0,0 +1,92 @@ +package persistence + +import ( + "time" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type apiKeyPers struct { + db *gorm.DB +} + +func NewApiKeyPers(db *gorm.DB) *apiKeyPers { + return &apiKeyPers{db: db} +} + +func (p *apiKeyPers) Create(apiKey *domain.ApiKey) error { + return p.db.Create(apiKey).Error +} + +func (p *apiKeyPers) GetById(id string) (*domain.ApiKey, error) { + var apiKey domain.ApiKey + err := p.db.Where("id = ?", id).First(&apiKey).Error + if err != nil { + return nil, err + } + return &apiKey, nil +} + +func (p *apiKeyPers) GetByKeyHash(keyHash string) (*domain.ApiKey, error) { + var apiKey domain.ApiKey + err := p.db. + Preload("User"). + Where("key_hash = ? AND deleted_at IS NULL", keyHash). + First(&apiKey).Error + if err != nil { + return nil, err + } + return &apiKey, nil +} + +func (p *apiKeyPers) GetByUserId(userId string) ([]domain.ApiKey, error) { + var apiKeys []domain.ApiKey + err := p.db. + Where("user_id = ? AND deleted_at IS NULL", userId). + Order("created_at DESC"). + Find(&apiKeys).Error + if err != nil { + return nil, err + } + return apiKeys, nil +} + +func (p *apiKeyPers) Update(apiKey *domain.ApiKey) error { + return p.db.Save(apiKey).Error +} + +func (p *apiKeyPers) Delete(id string) error { + return p.db.Where("id = ?", id).Delete(&domain.ApiKey{}).Error +} + +func (p *apiKeyPers) UpdateLastUsed(id string) error { + now := time.Now() + return p.db.Model(&domain.ApiKey{}).Where("id = ?", id).Update("last_used_at", &now).Error +} + +// Admin methods + +func (p *apiKeyPers) GetAll(limit, offset int) ([]domain.ApiKey, int64, error) { + var apiKeys []domain.ApiKey + var total int64 + + // Count total (excluding soft deleted) + if err := p.db.Model(&domain.ApiKey{}).Where("deleted_at IS NULL").Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated list with user preloaded + query := p.db.Model(&domain.ApiKey{}). + Preload("User"). + Where("deleted_at IS NULL"). + Order("created_at DESC") + if limit > 0 { + query = query.Limit(limit).Offset(offset) + } + if err := query.Find(&apiKeys).Error; err != nil { + return nil, 0, err + } + + return apiKeys, total, nil +} diff --git a/infrastructure/persistence/comment_pers.go b/infrastructure/persistence/comment_pers.go new file mode 100644 index 0000000..be7a23e --- /dev/null +++ b/infrastructure/persistence/comment_pers.go @@ -0,0 +1,61 @@ +package persistence + +import ( + "fmt" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type commentPers struct { + db *gorm.DB +} + +func NewCommentPers(db *gorm.DB) *commentPers { + return &commentPers{db: db} +} + +func (p *commentPers) Create(comment *domain.Comment) error { + return p.db.Create(comment).Error +} + +func (p *commentPers) GetById(commentId string) (*domain.Comment, error) { + var comment domain.Comment + err := p.db. + Preload("User"). + Preload("Parent"). + Where("id = ?", commentId). + First(&comment).Error + if err != nil { + return nil, err + } + return &comment, nil +} + +func (p *commentPers) GetByDocumentId(documentId string) ([]domain.Comment, error) { + var comments []domain.Comment + err := p.db. + Preload("User"). + Where("document_id = ? AND deleted_at IS NULL", documentId). + Order("created_at ASC"). + Find(&comments).Error + if err != nil { + return nil, err + } + return comments, nil +} + +func (p *commentPers) Update(comment *domain.Comment) error { + return p.db.Save(comment).Error +} + +func (p *commentPers) Delete(commentId string) error { + if err := p.db.Where("id = ?", commentId).Delete(&domain.Comment{}).Error; err != nil { + return fmt.Errorf("failed to delete comment: %w", err) + } + return nil +} + +func (p *commentPers) Resolve(commentId string, resolved bool) error { + return p.db.Model(&domain.Comment{}).Where("id = ?", commentId).Update("resolved", resolved).Error +} diff --git a/infrastructure/persistence/database_pers.go b/infrastructure/persistence/database_pers.go new file mode 100644 index 0000000..c75d624 --- /dev/null +++ b/infrastructure/persistence/database_pers.go @@ -0,0 +1,351 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type databasePers struct { + db *gorm.DB +} + +func NewDatabasePers(db *gorm.DB) *databasePers { + return &databasePers{db: db} +} + +func (p *databasePers) Create(database *domain.Database) error { + return p.db.Create(database).Error +} + +func (p *databasePers) GetById(id string) (*domain.Database, error) { + var database domain.Database + err := p.db.Debug(). + Preload("Space"). + Preload("User"). + Where("id = ?", id). + First(&database).Error + if err != nil { + return nil, err + } + return &database, nil +} + +func (p *databasePers) GetBySpaceId(spaceId string) ([]domain.Database, error) { + var databases []domain.Database + err := p.db.Debug(). + Preload("User"). + Where("space_id = ?", spaceId). + Order("created_at DESC"). + Find(&databases).Error + if err != nil { + return nil, err + } + return databases, nil +} + +func (p *databasePers) GetByDocumentId(documentId string) ([]domain.Database, error) { + var databases []domain.Database + err := p.db.Debug(). + Preload("User"). + Where("document_id = ?", documentId). + Order("created_at DESC"). + Find(&databases).Error + if err != nil { + return nil, err + } + return databases, nil +} + +func (p *databasePers) Update(database *domain.Database) error { + return p.db.Debug().Save(database).Error +} + +func (p *databasePers) Delete(id string) error { + return p.db.Debug().Where("id = ?", id).Delete(&domain.Database{}).Error +} + +func (p *databasePers) Search(query string, userId string, spaceId *string, limit int) ([]domain.Database, error) { + var databases []domain.Database + + if limit <= 0 || limit > 50 { + limit = 20 + } + + searchPattern := "%" + query + "%" + + // Subquery: group IDs the user belongs to + userGroupIds := p.db.Table("group_members").Select("group_id").Where("user_id = ?", userId) + + // Subquery: space IDs the user can access (public, owned, or via user/group permission) + accessibleSpaceIds := p.db.Table("space"). + Select("id"). + Where("deleted_at IS NULL"). + Where( + p.db.Where("type = ?", domain.SpaceTypePublic). + Or("owner_id = ?", userId). + Or("id IN (?)", + p.db.Table("permission"). + Select("space_id"). + Where("type = ? AND space_id IS NOT NULL AND deleted_at IS NULL", domain.PermissionTypeSpace). + Where("role != ?", domain.PermissionRoleDenied). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ), + ), + ) + + // Subquery: database IDs where user is explicitly denied + deniedDbIds := p.db.Table("permission"). + Select("database_id"). + Where("type = ? AND database_id IS NOT NULL AND deleted_at IS NULL AND role = ?", domain.PermissionTypeDatabase, domain.PermissionRoleDenied). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ) + + // Subquery: database IDs where user has explicit access (viewer+) + grantedDbIds := p.db.Table("permission"). + Select("database_id"). + Where("type = ? AND database_id IS NOT NULL AND deleted_at IS NULL", domain.PermissionTypeDatabase). + Where("role IN ?", []domain.PermissionRole{domain.PermissionRoleViewer, domain.PermissionRoleEditor, domain.PermissionRoleOwner}). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ) + + dbQuery := p.db. + Preload("Space"). + Preload("User"). + Where("deleted_at IS NULL"). + Where("(name LIKE ? OR description LIKE ? OR id IN (?))", + searchPattern, searchPattern, + p.db.Table("database_row"). + Select("database_id"). + Where("deleted_at IS NULL"). + Where("(properties LIKE ? OR content LIKE ?)", searchPattern, searchPattern), + ). + // Exclude databases where user is explicitly denied + Where("id NOT IN (?)", deniedDbIds). + // Must have either database-level access OR space-level access + Where( + p.db.Where("id IN (?)", grantedDbIds). + Or("space_id IN (?)", accessibleSpaceIds), + ) + + if spaceId != nil { + dbQuery = dbQuery.Where("space_id = ?", *spaceId) + } + + err := dbQuery. + Order("updated_at DESC"). + Limit(limit). + Find(&databases).Error + + if err != nil { + return nil, err + } + + return databases, nil +} + +// DatabaseRow persistence +type databaseRowPers struct { + db *gorm.DB +} + +func NewDatabaseRowPers(db *gorm.DB) *databaseRowPers { + return &databaseRowPers{db: db} +} + +func (p *databaseRowPers) Create(row *domain.DatabaseRow) error { + return p.db.Debug().Create(row).Error +} + +func (p *databaseRowPers) GetById(id string) (*domain.DatabaseRow, error) { + var row domain.DatabaseRow + err := p.db.Debug(). + Preload("Database"). + Preload("CreatedUser"). + Preload("UpdatedUser"). + Where("id = ?", id). + First(&row).Error + if err != nil { + return nil, err + } + return &row, nil +} + +func (p *databaseRowPers) GetByDatabaseId(databaseId string, limit, offset int) ([]domain.DatabaseRow, error) { + var rows []domain.DatabaseRow + query := p.db.Debug(). + Preload("CreatedUser"). + Preload("UpdatedUser"). + Where("database_id = ?", databaseId). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + if offset > 0 { + query = query.Offset(offset) + } + + err := query.Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (p *databaseRowPers) GetByDatabaseIdWithOptions(databaseId string, options domain.RowQueryOptions) ([]domain.DatabaseRow, error) { + var rows []domain.DatabaseRow + query := p.db.Debug(). + Preload("CreatedUser"). + Preload("UpdatedUser"). + Where("database_id = ?", databaseId) + + // Apply filters + query = p.applyFilters(query, options.Filter) + + // Apply sorting + if len(options.Sort) > 0 { + for _, sort := range options.Sort { + direction := "ASC" + if sort.Direction == "desc" { + direction = "DESC" + } + // Sort by JSON property value (SQLite compatible) + query = query.Order("json_extract(properties, '$." + sort.PropertyId + "') " + direction) + } + } else { + query = query.Order("created_at DESC") + } + + // Apply pagination + if options.Limit > 0 { + query = query.Limit(options.Limit) + } + if options.Offset > 0 { + query = query.Offset(options.Offset) + } + + err := query.Find(&rows).Error + if err != nil { + return nil, err + } + return rows, nil +} + +func (p *databaseRowPers) applyFilters(query *gorm.DB, filter *domain.FilterConfig) *gorm.DB { + if filter == nil { + return query + } + + // Apply AND filters + for _, rule := range filter.And { + query = p.applyFilterRule(query, rule, "AND") + } + + // Apply OR filters + if len(filter.Or) > 0 { + orQuery := p.db.Where("1 = 0") // Start with false for OR + for _, rule := range filter.Or { + orQuery = p.applyFilterRule(orQuery, rule, "OR") + } + query = query.Where(orQuery) + } + + return query +} + +func (p *databaseRowPers) applyFilterRule(query *gorm.DB, rule domain.FilterRule, _ string) *gorm.DB { + // Use json_extract for SQLite compatibility + propertyPath := "json_extract(properties, '$." + rule.Property + "')" + + // Get string value safely + strValue := "" + if rule.Value != nil { + if s, ok := rule.Value.(string); ok { + strValue = s + } + } + + switch rule.Condition { + case "eq": + if rule.Value == nil || strValue == "" { + return query // Skip empty eq filters + } + return query.Where(propertyPath+" = ?", rule.Value) + case "neq": + if rule.Value == nil || strValue == "" { + return query // Skip empty neq filters + } + return query.Where(propertyPath+" != ? OR "+propertyPath+" IS NULL", rule.Value) + case "gt": + return query.Where("CAST("+propertyPath+" AS REAL) > ?", rule.Value) + case "lt": + return query.Where("CAST("+propertyPath+" AS REAL) < ?", rule.Value) + case "gte": + return query.Where("CAST("+propertyPath+" AS REAL) >= ?", rule.Value) + case "lte": + return query.Where("CAST("+propertyPath+" AS REAL) <= ?", rule.Value) + case "contains": + if strValue == "" { + return query // Skip empty contains filters + } + return query.Where(propertyPath+" LIKE ? COLLATE NOCASE", "%"+strValue+"%") + case "not_contains": + if strValue == "" { + return query // Skip empty not_contains filters + } + return query.Where(propertyPath+" NOT LIKE ? COLLATE NOCASE OR "+propertyPath+" IS NULL", "%"+strValue+"%") + case "starts_with": + if strValue == "" { + return query // Skip empty starts_with filters + } + return query.Where(propertyPath+" LIKE ? COLLATE NOCASE", strValue+"%") + case "ends_with": + if strValue == "" { + return query // Skip empty ends_with filters + } + return query.Where(propertyPath+" LIKE ? COLLATE NOCASE", "%"+strValue) + case "is_empty": + return query.Where(propertyPath+" IS NULL OR "+propertyPath+" = ''") + case "is_not_empty": + return query.Where(propertyPath+" IS NOT NULL AND "+propertyPath+" != ''") + default: + return query + } +} + +func (p *databaseRowPers) GetRowCount(databaseId string) (int64, error) { + var count int64 + err := p.db.Debug().Model(&domain.DatabaseRow{}). + Where("database_id = ? AND deleted_at IS NULL", databaseId). + Count(&count).Error + return count, err +} + +func (p *databaseRowPers) GetRowCountWithFilter(databaseId string, filter *domain.FilterConfig) (int64, error) { + var count int64 + query := p.db.Debug().Model(&domain.DatabaseRow{}). + Where("database_id = ? AND deleted_at IS NULL", databaseId) + + query = p.applyFilters(query, filter) + + err := query.Count(&count).Error + return count, err +} + +func (p *databaseRowPers) Update(row *domain.DatabaseRow) error { + return p.db.Debug().Save(row).Error +} + +func (p *databaseRowPers) Delete(id string) error { + return p.db.Debug().Where("id = ?", id).Delete(&domain.DatabaseRow{}).Error +} + +func (p *databaseRowPers) BulkDelete(ids []string) error { + return p.db.Debug().Where("id IN ?", ids).Delete(&domain.DatabaseRow{}).Error +} diff --git a/infrastructure/persistence/document_pers.go b/infrastructure/persistence/document_pers.go new file mode 100644 index 0000000..b0dc1aa --- /dev/null +++ b/infrastructure/persistence/document_pers.go @@ -0,0 +1,492 @@ +package persistence + +import ( + "fmt" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type documentPers struct { + db *gorm.DB +} + +func NewDocumentPers(db *gorm.DB) *documentPers { + return &documentPers{db: db} +} + +func (p *documentPers) GetDocumentWithPermissions(documentId, userId string) (*domain.Document, error) { + var doc domain.Document + err := p.db. + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + Where("id = ?", documentId). + First(&doc).Error + if err != nil { + return nil, err + } + return &doc, nil +} + +func (p *documentPers) GetDocumentByIdOrSlugWithUserPermissions(spaceId string, id *string, slug *string, userId string) (*domain.Document, error) { + var doc domain.Document + + query := p.db.Debug(). + // Preload the space along with owner and its permissions for the user + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + // Preload only the document's permissions for this user + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + // Optionally preload the parent if needed + Preload("Parent"). + Where("space_id = ?", spaceId) + + if id != nil { + query = query.Where("id = ?", *id) + } else if slug != nil { + query = query.Where("slug = ?", *slug) + } else { + return nil, fmt.Errorf("either id or slug must be provided") + } + + err := query.First(&doc).Error + if err != nil { + return nil, err + } + + // Verify if the user has at least viewer permissions + if !doc.HasPermission(userId, domain.PermissionRoleViewer) { + return nil, fmt.Errorf("access denied: insufficient permissions") + } + + return &doc, nil +} + +func (p *documentPers) GetRootDocumentsFromSpaceWithUserPermissions(spaceId, userId string) ([]domain.Document, error) { + var docs []domain.Document + + err := p.db.Debug(). + // Preload space with owner and permissions for the user + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + // Preload document permissions for this user + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + Where("space_id = ? AND parent_id IS NULL AND deleted_at IS NULL", spaceId). + Order("position ASC, created_at ASC"). + Find(&docs).Error + + if err != nil { + return nil, err + } + + // Filter documents based on permissions + var accessibleDocs []domain.Document + for _, doc := range docs { + if doc.HasPermission(userId, domain.PermissionRoleViewer) { + accessibleDocs = append(accessibleDocs, doc) + } + } + + return accessibleDocs, nil +} + +func (p *documentPers) GetChildDocumentsWithUserPermissions(parentId, userId string) ([]domain.Document, error) { + var docs []domain.Document + + err := p.db.Debug(). + // Preload space with owner and permissions for the user + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + // Preload document permissions for this user + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + // Preload the parent to have the complete context + Preload("Parent"). + Where("parent_id = ? AND deleted_at IS NULL", parentId). + Order("position ASC, created_at ASC"). + Find(&docs).Error + + if err != nil { + return nil, err + } + + // Filter documents based on permissions + var accessibleDocs []domain.Document + for _, doc := range docs { + if doc.HasPermission(userId, domain.PermissionRoleViewer) { + accessibleDocs = append(accessibleDocs, doc) + } + } + + return accessibleDocs, nil +} + +func (p *documentPers) Create(document *domain.Document, userId string) error { + // If the document has a parent, check permissions on the parent + if document.ParentId != nil { + parentDoc, err := p.GetDocumentWithPermissions(*document.ParentId, userId) + if err != nil { + return fmt.Errorf("failed to get parent document: %w", err) + } + + // Check if the user can edit the parent document + if !parentDoc.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to create document in parent") + } + } else { + // If it's a root document, check permissions on the space + var space domain.Space + err := p.db.Debug(). + Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + Where("id = ?", document.SpaceId). + First(&space).Error + if err != nil { + return fmt.Errorf("failed to get space: %w", err) + } + + // Check if the user can edit in the space + if !space.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to create document in space") + } + } + + // Assign position at the end + maxPos, err := p.GetMaxPosition(document.SpaceId, document.ParentId) + if err != nil { + return fmt.Errorf("failed to get max position: %w", err) + } + document.Position = maxPos + 1 + + // Perform the creation + return p.db.Debug().Create(document).Error +} + +func (p *documentPers) Update(document *domain.Document, userId string) error { + // Get the existing document with permissions + existingDoc, err := p.GetDocumentWithPermissions(document.Id, userId) + if err != nil { + return fmt.Errorf("failed to get document: %w", err) + } + + // Check if the user can edit the document + if !existingDoc.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to update document") + } + + // Perform the update + return p.db.Debug().Save(document).Error +} + +func (p *documentPers) Delete(documentId, userId string) error { + // Load document with permissions for the current user + existingDoc, err := p.GetDocumentWithPermissions(documentId, userId) + if err != nil { + return fmt.Errorf("failed to get document: %w", err) + } + + // Check if the user can edit/delete the document + if !existingDoc.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to delete document") + } + + // Prevent delete if there are active children + var childrenCount int64 + if err := p.db.Model(&domain.Document{}).Where("parent_id = ? AND deleted_at IS NULL", documentId).Count(&childrenCount).Error; err != nil { + return fmt.Errorf("failed to check child documents: %w", err) + } + if childrenCount > 0 { + return fmt.Errorf("cannot delete document with existing child documents") + } + + // Soft delete + if err := p.db.Where("id = ?", documentId).Delete(&domain.Document{}).Error; err != nil { + return fmt.Errorf("failed to delete document: %w", err) + } + + return nil +} + +func (p *documentPers) Move(documentId string, newParentId *string, userId string) (*domain.Document, error) { + // Load the document to move with permissions + doc, err := p.GetDocumentWithPermissions(documentId, userId) + if err != nil { + return nil, fmt.Errorf("failed to get document: %w", err) + } + + if !doc.HasPermission(userId, domain.PermissionRoleEditor) { + return nil, fmt.Errorf("access denied: insufficient permissions to move document") + } + + // Prevent self-parenting + if newParentId != nil && *newParentId == documentId { + return nil, fmt.Errorf("invalid move: cannot set document as its own parent") + } + + // If moving under a parent, check permissions on parent and same space + if newParentId != nil { + parent, err := p.GetDocumentWithPermissions(*newParentId, userId) + if err != nil { + return nil, fmt.Errorf("failed to get parent document: %w", err) + } + if !parent.HasPermission(userId, domain.PermissionRoleEditor) { + return nil, fmt.Errorf("access denied: insufficient permissions on target parent") + } + if parent.SpaceId != doc.SpaceId { + return nil, fmt.Errorf("invalid move: parent must be in the same space") + } + doc.ParentId = newParentId + } else { + // Move to root + doc.ParentId = nil + } + + // Assign position at the end of the new parent's children + maxPos, err := p.GetMaxPosition(doc.SpaceId, doc.ParentId) + if err != nil { + return nil, fmt.Errorf("failed to get max position: %w", err) + } + doc.Position = maxPos + 1 + + if err := p.db.Save(doc).Error; err != nil { + return nil, fmt.Errorf("failed to move document: %w", err) + } + + return doc, nil +} + +func (p *documentPers) GetDeletedDocuments(spaceId, userId string) ([]domain.Document, error) { + var docs []domain.Document + + err := p.db.Unscoped(). + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + Where("space_id = ? AND deleted_at IS NOT NULL", spaceId). + Order("deleted_at DESC"). + Find(&docs).Error + + if err != nil { + return nil, err + } + + // Filter based on space permissions (user must be at least editor to see trash) + var accessibleDocs []domain.Document + for _, doc := range docs { + if doc.Space.HasPermission(userId, domain.PermissionRoleEditor) { + accessibleDocs = append(accessibleDocs, doc) + } + } + + return accessibleDocs, nil +} + +func (p *documentPers) Restore(documentId, userId string) error { + // Get the deleted document (unscoped to include soft-deleted) + var doc domain.Document + err := p.db.Unscoped(). + Preload("Space", func(db *gorm.DB) *gorm.DB { + return db.Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId) + }). + Where("id = ?", documentId). + First(&doc).Error + if err != nil { + return fmt.Errorf("document not found: %w", err) + } + + // Check if user has editor permission on the space + if !doc.Space.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to restore document") + } + + // Check if document is actually deleted + if !doc.DeletedAt.Valid { + return fmt.Errorf("document is not deleted") + } + + // If document had a parent, check if parent still exists + if doc.ParentId != nil { + var parent domain.Document + err := p.db.Where("id = ? AND deleted_at IS NULL", *doc.ParentId).First(&parent).Error + if err != nil { + // Parent doesn't exist or is deleted, restore to root + doc.ParentId = nil + } + } + + // Restore the document by clearing deleted_at + return p.db.Unscoped().Model(&doc).Update("deleted_at", nil).Error +} + +func (p *documentPers) SetPublic(documentId string, public bool, userId string) error { + // Get the document with permissions + doc, err := p.GetDocumentWithPermissions(documentId, userId) + if err != nil { + return fmt.Errorf("document not found: %w", err) + } + + // Check if user has editor permission + if !doc.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions") + } + + return p.db.Model(&domain.Document{}).Where("id = ?", documentId).Update("public", public).Error +} + +func (p *documentPers) GetPublicDocument(spaceId string, id *string, slug *string) (*domain.Document, error) { + var doc domain.Document + + query := p.db. + Preload("Space"). + Preload("Parent"). + Where("space_id = ? AND public = ?", spaceId, true) + + if id != nil { + query = query.Where("id = ?", *id) + } else if slug != nil { + query = query.Where("slug = ?", *slug) + } else { + return nil, fmt.Errorf("either id or slug must be provided") + } + + err := query.First(&doc).Error + if err != nil { + return nil, err + } + + return &doc, nil +} + +func (p *documentPers) Search(query string, userId string, spaceId *string, limit int) ([]domain.Document, error) { + var docs []domain.Document + + if limit <= 0 || limit > 50 { + limit = 20 + } + + searchPattern := "%" + query + "%" + + // Subquery: group IDs the user belongs to + userGroupIds := p.db.Table("group_members").Select("group_id").Where("user_id = ?", userId) + + // Subquery: space IDs the user can access (public, owned, or via user/group permission) + accessibleSpaceIds := p.db.Table("space"). + Select("id"). + Where("deleted_at IS NULL"). + Where( + p.db.Where("type = ?", domain.SpaceTypePublic). + Or("owner_id = ?", userId). + Or("id IN (?)", + p.db.Table("permission"). + Select("space_id"). + Where("type = ? AND space_id IS NOT NULL AND deleted_at IS NULL", domain.PermissionTypeSpace). + Where("role != ?", domain.PermissionRoleDenied). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ), + ), + ) + + // Subquery: document IDs where user is explicitly denied + deniedDocIds := p.db.Table("permission"). + Select("document_id"). + Where("type = ? AND document_id IS NOT NULL AND deleted_at IS NULL AND role = ?", domain.PermissionTypeDocument, domain.PermissionRoleDenied). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ) + + // Subquery: document IDs where user has explicit access (viewer+) + grantedDocIds := p.db.Table("permission"). + Select("document_id"). + Where("type = ? AND document_id IS NOT NULL AND deleted_at IS NULL", domain.PermissionTypeDocument). + Where("role IN ?", []domain.PermissionRole{domain.PermissionRoleViewer, domain.PermissionRoleEditor, domain.PermissionRoleOwner}). + Where( + p.db.Where("user_id = ?", userId). + Or("group_id IN (?)", userGroupIds), + ) + + dbQuery := p.db. + Preload("Space"). + Where("deleted_at IS NULL"). + Where("(name LIKE ? OR content LIKE ?)", searchPattern, searchPattern). + // Exclude documents where user is explicitly denied + Where("id NOT IN (?)", deniedDocIds). + // Must have either document-level access OR space-level access + Where( + p.db.Where("id IN (?)", grantedDocIds). + Or("space_id IN (?)", accessibleSpaceIds), + ) + + if spaceId != nil { + dbQuery = dbQuery.Where("space_id = ?", *spaceId) + } + + err := dbQuery. + Order("updated_at DESC"). + Limit(limit). + Find(&docs).Error + + if err != nil { + return nil, err + } + + return docs, nil +} + +func (p *documentPers) Reorder(spaceId string, items []domain.ReorderItem, userId string) error { + // Verify user has editor access on the space + var space domain.Space + err := p.db.Debug(). + Preload("Owner"). + Preload("Permissions", "user_id = ? AND deleted_at IS NULL", userId). + Where("id = ?", spaceId). + First(&space).Error + if err != nil { + return fmt.Errorf("failed to get space: %w", err) + } + if !space.HasPermission(userId, domain.PermissionRoleEditor) { + return fmt.Errorf("access denied: insufficient permissions to reorder documents") + } + + // Update positions in a transaction + return p.db.Transaction(func(tx *gorm.DB) error { + for _, item := range items { + if err := tx.Model(&domain.Document{}). + Where("id = ? AND space_id = ?", item.Id, spaceId). + Update("position", item.Position).Error; err != nil { + return fmt.Errorf("failed to update position for document %s: %w", item.Id, err) + } + } + return nil + }) +} + +func (p *documentPers) GetMaxPosition(spaceId string, parentId *string) (int, error) { + var maxPos int + query := p.db.Model(&domain.Document{}). + Where("space_id = ? AND deleted_at IS NULL", spaceId) + + if parentId != nil { + query = query.Where("parent_id = ?", *parentId) + } else { + query = query.Where("parent_id IS NULL") + } + + err := query.Select("COALESCE(MAX(position), -1)").Scan(&maxPos).Error + if err != nil { + return -1, err + } + return maxPos, nil +} diff --git a/infrastructure/persistence/document_version_pers.go b/infrastructure/persistence/document_version_pers.go new file mode 100644 index 0000000..9794ce5 --- /dev/null +++ b/infrastructure/persistence/document_version_pers.go @@ -0,0 +1,105 @@ +package persistence + +import ( + "fmt" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type documentVersionPers struct { + db *gorm.DB +} + +func NewDocumentVersionPers(db *gorm.DB) *documentVersionPers { + return &documentVersionPers{db: db} +} + +func (p *documentVersionPers) Create(version *domain.DocumentVersion) error { + return p.db.Debug().Create(version).Error +} + +func (p *documentVersionPers) GetByDocumentId(documentId string, limit int, offset int) ([]domain.DocumentVersion, error) { + var versions []domain.DocumentVersion + + if limit <= 0 || limit > 100 { + limit = 20 + } + + err := p.db.Debug(). + Preload("User"). + Where("document_id = ?", documentId). + Order("version DESC"). + Limit(limit). + Offset(offset). + Find(&versions).Error + + if err != nil { + return nil, err + } + + return versions, nil +} + +func (p *documentVersionPers) GetById(versionId string) (*domain.DocumentVersion, error) { + var version domain.DocumentVersion + err := p.db.Debug(). + Preload("User"). + Preload("Document"). + Where("id = ?", versionId). + First(&version).Error + if err != nil { + return nil, err + } + return &version, nil +} + +func (p *documentVersionPers) GetLatestVersion(documentId string) (*domain.DocumentVersion, error) { + var version domain.DocumentVersion + err := p.db.Debug(). + Where("document_id = ?", documentId). + Order("version DESC"). + First(&version).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, err + } + return &version, nil +} + +func (p *documentVersionPers) GetVersionCount(documentId string) (int64, error) { + var count int64 + err := p.db.Debug().Model(&domain.DocumentVersion{}). + Where("document_id = ?", documentId). + Count(&count).Error + return count, err +} + +func (p *documentVersionPers) DeleteOldVersions(documentId string, keepCount int) error { + if keepCount <= 0 { + return fmt.Errorf("keepCount must be positive") + } + + // Get IDs of versions to keep + var versionsToKeep []string + err := p.db.Debug().Model(&domain.DocumentVersion{}). + Select("id"). + Where("document_id = ?", documentId). + Order("version DESC"). + Limit(keepCount). + Pluck("id", &versionsToKeep).Error + if err != nil { + return err + } + + if len(versionsToKeep) == 0 { + return nil + } + + // Delete versions not in the keep list + return p.db.Debug(). + Where("document_id = ? AND id NOT IN ?", documentId, versionsToKeep). + Delete(&domain.DocumentVersion{}).Error +} diff --git a/infrastructure/persistence/drawing_pers.go b/infrastructure/persistence/drawing_pers.go new file mode 100644 index 0000000..2c138e9 --- /dev/null +++ b/infrastructure/persistence/drawing_pers.go @@ -0,0 +1,65 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type drawingPers struct { + db *gorm.DB +} + +func NewDrawingPers(db *gorm.DB) *drawingPers { + return &drawingPers{db: db} +} + +func (p *drawingPers) Create(drawing *domain.Drawing) error { + return p.db.Create(drawing).Error +} + +func (p *drawingPers) GetById(id string) (*domain.Drawing, error) { + var drawing domain.Drawing + err := p.db.Debug(). + Preload("Space"). + Preload("User"). + Where("id = ?", id). + First(&drawing).Error + if err != nil { + return nil, err + } + return &drawing, nil +} + +func (p *drawingPers) GetBySpaceId(spaceId string) ([]domain.Drawing, error) { + var drawings []domain.Drawing + err := p.db.Debug(). + Preload("User"). + Where("space_id = ?", spaceId). + Order("created_at DESC"). + Find(&drawings).Error + if err != nil { + return nil, err + } + return drawings, nil +} + +func (p *drawingPers) GetByDocumentId(documentId string) ([]domain.Drawing, error) { + var drawings []domain.Drawing + err := p.db.Debug(). + Preload("User"). + Where("document_id = ?", documentId). + Order("created_at DESC"). + Find(&drawings).Error + if err != nil { + return nil, err + } + return drawings, nil +} + +func (p *drawingPers) Update(drawing *domain.Drawing) error { + return p.db.Debug().Save(drawing).Error +} + +func (p *drawingPers) Delete(id string) error { + return p.db.Debug().Where("id = ?", id).Delete(&domain.Drawing{}).Error +} diff --git a/infrastructure/persistence/favorite_pers.go b/infrastructure/persistence/favorite_pers.go new file mode 100644 index 0000000..b87d786 --- /dev/null +++ b/infrastructure/persistence/favorite_pers.go @@ -0,0 +1,59 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type favoritePers struct { + db *gorm.DB +} + +func NewFavoritePers(db *gorm.DB) *favoritePers { + return &favoritePers{db: db} +} + +func (p *favoritePers) GetLatestFavoritePositionByUser(userId string) (int, error) { + var position int + err := p.db.Model(&domain.Favorite{}).Select("position").Where("user_id = ?", userId).Order("position desc").Limit(1).Scan(&position).Error + if err != nil { + return 0, err + } + return position, nil +} + +func (p *favoritePers) Create(favorite *domain.Favorite) error { + return p.db.Create(favorite).Error +} + +func (p *favoritePers) Delete(documentId, userId string, spaceId string) error { + return p.db.Where("document_id = ? AND user_id = ? AND space_id = ?", documentId, userId, spaceId).Delete(&domain.Favorite{}).Error +} + +func (f *favoritePers) GetMyFavoritesWithMainDocumentInformations(userId string) ([]domain.Favorite, error) { + var favorites []domain.Favorite + err := f.db. + Preload("Document", func(db *gorm.DB) *gorm.DB { + return db.Select("id, name, slug, config") + }). + Where("user_id = ?", userId). + Order("position asc"). + Find(&favorites).Error + if err != nil { + return nil, err + } + return favorites, nil +} + +func (f *favoritePers) UpdateFavoritePosition(favorite *domain.Favorite) error { + return f.db.Save(favorite).Error +} + +func (f *favoritePers) GetFavoriteByIdAndUserId(favoriteId, userId string) (*domain.Favorite, error) { + var favorite domain.Favorite + err := f.db.Where("id = ? AND user_id = ?", favoriteId, userId).First(&favorite).Error + if err != nil { + return nil, err + } + return &favorite, nil +} diff --git a/infrastructure/persistence/group_pers.go b/infrastructure/persistence/group_pers.go new file mode 100644 index 0000000..3bf068d --- /dev/null +++ b/infrastructure/persistence/group_pers.go @@ -0,0 +1,87 @@ +package persistence + +import ( + "github.com/google/uuid" + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type groupPers struct { + db *gorm.DB +} + +func NewGroupPers(db *gorm.DB) *groupPers { + return &groupPers{db: db} +} + +func (g *groupPers) Create(group *domain.Group) error { + if group.Id == "" { + group.Id = uuid.New().String() + } + return g.db.Create(group).Error +} + +func (g *groupPers) GetById(groupId string) (*domain.Group, error) { + var group domain.Group + err := g.db.Preload("Members").Preload("OwnerUser").Where("id = ?", groupId).First(&group).Error + if err != nil { + return nil, err + } + return &group, nil +} + +func (g *groupPers) GetAll(limit, offset int) ([]domain.Group, int64, error) { + var groups []domain.Group + var total int64 + + // Count total + if err := g.db.Model(&domain.Group{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results with members and owner + err := g.db.Preload("Members").Preload("OwnerUser"). + Order("created_at DESC"). + Limit(limit). + Offset(offset). + Find(&groups).Error + + return groups, total, err +} + +func (g *groupPers) Update(group *domain.Group) error { + return g.db.Model(group).Updates(map[string]interface{}{ + "name": group.Name, + "description": group.Description, + "role": group.Role, + }).Error +} + +func (g *groupPers) Delete(groupId string) error { + // First remove all members + if err := g.db.Exec("DELETE FROM group_members WHERE group_id = ?", groupId).Error; err != nil { + return err + } + // Then delete the group + return g.db.Delete(&domain.Group{}, "id = ?", groupId).Error +} + +func (g *groupPers) AddMember(groupId, userId string) error { + return g.db.Exec( + "INSERT INTO group_members (group_id, user_id, created_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT DO NOTHING", + groupId, userId, + ).Error +} + +func (g *groupPers) RemoveMember(groupId, userId string) error { + return g.db.Exec("DELETE FROM group_members WHERE group_id = ? AND user_id = ?", groupId, userId).Error +} + +func (g *groupPers) GetMembers(groupId string) ([]domain.User, error) { + var users []domain.User + err := g.db. + Joins("JOIN group_members ON group_members.user_id = \"user\".id"). + Where("group_members.group_id = ?", groupId). + Find(&users).Error + return users, err +} diff --git a/infrastructure/persistence/permission_pers.go b/infrastructure/persistence/permission_pers.go new file mode 100644 index 0000000..ee3c326 --- /dev/null +++ b/infrastructure/persistence/permission_pers.go @@ -0,0 +1,164 @@ +package persistence + +import ( + "github.com/gofiber/fiber/v2/utils" + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type permissionPers struct { + db *gorm.DB +} + +func NewPermissionPers(db *gorm.DB) *permissionPers { + return &permissionPers{db: db} +} + +// getResourceColumn returns the column name for the resource type +func getResourceColumn(resourceType domain.PermissionType) string { + switch resourceType { + case domain.PermissionTypeSpace: + return "space_id" + case domain.PermissionTypeDocument: + return "document_id" + case domain.PermissionTypeDatabase: + return "database_id" + case domain.PermissionTypeDrawing: + return "drawing_id" + default: + return "" + } +} + +func (p *permissionPers) ListByResource(resourceType domain.PermissionType, resourceId string) ([]domain.Permission, error) { + var perms []domain.Permission + column := getResourceColumn(resourceType) + if column == "" { + return nil, nil + } + + err := p.db.Preload("User").Preload("Group"). + Where("type = ? AND "+column+" = ? AND deleted_at IS NULL", resourceType, resourceId). + Find(&perms).Error + return perms, err +} + +func (p *permissionPers) GetByResourceAndUser(resourceType domain.PermissionType, resourceId, userId string) (*domain.Permission, error) { + var perm domain.Permission + column := getResourceColumn(resourceType) + if column == "" { + return nil, nil + } + + err := p.db.Where("type = ? AND "+column+" = ? AND user_id = ? AND deleted_at IS NULL", resourceType, resourceId, userId). + First(&perm).Error + if err != nil { + return nil, err + } + return &perm, nil +} + +func (p *permissionPers) GetByResourceAndGroup(resourceType domain.PermissionType, resourceId, groupId string) (*domain.Permission, error) { + var perm domain.Permission + column := getResourceColumn(resourceType) + if column == "" { + return nil, nil + } + + err := p.db.Where("type = ? AND "+column+" = ? AND group_id = ? AND deleted_at IS NULL", resourceType, resourceId, groupId). + First(&perm).Error + if err != nil { + return nil, err + } + return &perm, nil +} + +func (p *permissionPers) UpsertUser(resourceType domain.PermissionType, resourceId, userId string, role domain.PermissionRole) error { + column := getResourceColumn(resourceType) + if column == "" { + return nil + } + + var perm domain.Permission + err := p.db.Where("type = ? AND "+column+" = ? AND user_id = ?", resourceType, resourceId, userId).First(&perm).Error + if err != nil { + // Create if not exists + perm = domain.Permission{ + Id: utils.UUIDv4(), + Type: resourceType, + UserId: &userId, + Role: role, + } + // Set the appropriate resource ID + switch resourceType { + case domain.PermissionTypeSpace: + perm.SpaceId = &resourceId + case domain.PermissionTypeDocument: + perm.DocumentId = &resourceId + case domain.PermissionTypeDatabase: + perm.DatabaseId = &resourceId + case domain.PermissionTypeDrawing: + perm.DrawingId = &resourceId + } + return p.db.Create(&perm).Error + } + // Update existing + perm.Role = role + perm.DeletedAt = gorm.DeletedAt{} // Restore if soft-deleted + return p.db.Save(&perm).Error +} + +func (p *permissionPers) UpsertGroup(resourceType domain.PermissionType, resourceId, groupId string, role domain.PermissionRole) error { + column := getResourceColumn(resourceType) + if column == "" { + return nil + } + + var perm domain.Permission + err := p.db.Where("type = ? AND "+column+" = ? AND group_id = ?", resourceType, resourceId, groupId).First(&perm).Error + if err != nil { + // Create if not exists + perm = domain.Permission{ + Id: utils.UUIDv4(), + Type: resourceType, + GroupId: &groupId, + Role: role, + } + // Set the appropriate resource ID + switch resourceType { + case domain.PermissionTypeSpace: + perm.SpaceId = &resourceId + case domain.PermissionTypeDocument: + perm.DocumentId = &resourceId + case domain.PermissionTypeDatabase: + perm.DatabaseId = &resourceId + case domain.PermissionTypeDrawing: + perm.DrawingId = &resourceId + } + return p.db.Create(&perm).Error + } + // Update existing + perm.Role = role + perm.DeletedAt = gorm.DeletedAt{} // Restore if soft-deleted + return p.db.Save(&perm).Error +} + +func (p *permissionPers) DeleteUser(resourceType domain.PermissionType, resourceId, userId string) error { + column := getResourceColumn(resourceType) + if column == "" { + return nil + } + + return p.db.Where("type = ? AND "+column+" = ? AND user_id = ?", resourceType, resourceId, userId). + Delete(&domain.Permission{}).Error +} + +func (p *permissionPers) DeleteGroup(resourceType domain.PermissionType, resourceId, groupId string) error { + column := getResourceColumn(resourceType) + if column == "" { + return nil + } + + return p.db.Where("type = ? AND "+column+" = ? AND group_id = ?", resourceType, resourceId, groupId). + Delete(&domain.Permission{}).Error +} diff --git a/infrastructure/persistence/session_pers.go b/infrastructure/persistence/session_pers.go new file mode 100644 index 0000000..5d70a0b --- /dev/null +++ b/infrastructure/persistence/session_pers.go @@ -0,0 +1,31 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type sessionPers struct { + db *gorm.DB +} + +func NewSessionPers(db *gorm.DB) *sessionPers { + return &sessionPers{db: db} +} + +func (s *sessionPers) Create(session *domain.Session) error { + return s.db.Create(session).Error +} + +func (s *sessionPers) GetById(id string) (*domain.Session, error) { + var session domain.Session + err := s.db.Where("id = ?", id).First(&session).Error + if err != nil { + return nil, err + } + return &session, nil +} + +func (s *sessionPers) DeleteById(id string) error { + return s.db.Where("id = ?", id).Delete(&domain.Session{}).Error +} diff --git a/infrastructure/persistence/space_pers.go b/infrastructure/persistence/space_pers.go new file mode 100644 index 0000000..7a9c4fe --- /dev/null +++ b/infrastructure/persistence/space_pers.go @@ -0,0 +1,86 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type spacePers struct { + db *gorm.DB +} + +func NewSpacePers(db *gorm.DB) *spacePers { + return &spacePers{db: db} +} + +func (s *spacePers) Create(space *domain.Space) error { + return s.db.Create(space).Error +} + +func (s *spacePers) GetSpacesForUser(userId string) ([]domain.Space, error) { + var spaces []domain.Space + + err := s.db.Preload("Owner"). + Preload("Permissions", "type = ?", domain.PermissionTypeSpace). + Preload("Permissions.User"). + Preload("Permissions.Group"). + Where( + s.db.Where("owner_id = ?", userId). + Or("id IN (?)", + s.db.Table("permission"). + Select("space_id"). + Where("type = ? AND user_id = ? AND deleted_at IS NULL", domain.PermissionTypeSpace, userId), + ). + Or("type = ?", domain.SpaceTypePublic), + ). + Find(&spaces).Error + + return spaces, err +} + +func (s *spacePers) GetSpaceById(spaceId string) (*domain.Space, error) { + var space domain.Space + + err := s.db.Preload("Owner"). + Preload("Permissions", "type = ?", domain.PermissionTypeSpace). + Preload("Permissions.User"). + Preload("Permissions.Group"). + First(&space, "id = ?", spaceId).Error + + if err != nil { + return nil, err + } + + return &space, nil +} + +func (s *spacePers) Update(space *domain.Space) error { + return s.db.Save(space).Error +} + +func (s *spacePers) Delete(spaceId string) error { + return s.db.Where("id = ?", spaceId).Delete(&domain.Space{}).Error +} + +// Admin methods + +func (s *spacePers) GetAll(limit, offset int) ([]domain.Space, int64, error) { + var spaces []domain.Space + var total int64 + + // Count total + if err := s.db.Model(&domain.Space{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated list with owner preloaded + query := s.db.Model(&domain.Space{}).Preload("Owner").Order("created_at DESC") + if limit > 0 { + query = query.Limit(limit).Offset(offset) + } + if err := query.Find(&spaces).Error; err != nil { + return nil, 0, err + } + + return spaces, total, nil +} diff --git a/infrastructure/persistence/user_pers.go b/infrastructure/persistence/user_pers.go new file mode 100644 index 0000000..afc4cd6 --- /dev/null +++ b/infrastructure/persistence/user_pers.go @@ -0,0 +1,84 @@ +package persistence + +import ( + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type userPers struct { + db *gorm.DB +} + +func NewUserPers(db *gorm.DB) *userPers { + return &userPers{db: db} +} + +func (u *userPers) GetByUsername(username string) (domain.User, error) { + var user domain.User + err := u.db.Debug().Where("username = ?", username).First(&user).Error + return user, err +} + +func (u *userPers) GetByEmail(email string) (domain.User, error) { + var user domain.User + err := u.db.Debug().Where("email = ?", email).First(&user).Error + return user, err +} + +func (u *userPers) Create(user domain.User) (domain.User, error) { + err := u.db.Create(&user).Error + return user, err +} + +func (u *userPers) GetById(id string) (domain.User, error) { + var user domain.User + err := u.db.Debug().Where("id = ?", id).First(&user).Error + return user, err +} + +func (u *userPers) Update(user *domain.User) error { + return u.db.Model(user).Updates(map[string]interface{}{ + "username": user.Username, + "avatar_url": user.AvatarUrl, + "preferences": user.Preferences, + }).Error +} + +func (u *userPers) UpdatePassword(userId, hashedPassword string) error { + return u.db.Model(&domain.User{}).Where("id = ?", userId).Update("password", hashedPassword).Error +} + +// Admin methods + +func (u *userPers) GetAll(limit, offset int) ([]domain.User, int64, error) { + var users []domain.User + var total int64 + + // Count total + if err := u.db.Model(&domain.User{}).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated list + query := u.db.Model(&domain.User{}).Order("created_at DESC") + if limit > 0 { + query = query.Limit(limit).Offset(offset) + } + if err := query.Find(&users).Error; err != nil { + return nil, 0, err + } + + return users, total, nil +} + +func (u *userPers) UpdateRole(userId string, role domain.Role) error { + return u.db.Model(&domain.User{}).Where("id = ?", userId).Update("role", role).Error +} + +func (u *userPers) UpdateActive(userId string, active bool) error { + return u.db.Model(&domain.User{}).Where("id = ?", userId).Update("active", active).Error +} + +func (u *userPers) Delete(userId string) error { + return u.db.Where("id = ?", userId).Delete(&domain.User{}).Error +} diff --git a/infrastructure/persistence/webhook_pers.go b/infrastructure/persistence/webhook_pers.go new file mode 100644 index 0000000..52681e9 --- /dev/null +++ b/infrastructure/persistence/webhook_pers.go @@ -0,0 +1,128 @@ +package persistence + +import ( + "time" + + "github.com/labbs/nexo/domain" + "gorm.io/gorm" +) + +type webhookPers struct { + db *gorm.DB +} + +func NewWebhookPers(db *gorm.DB) *webhookPers { + return &webhookPers{db: db} +} + +func (p *webhookPers) Create(webhook *domain.Webhook) error { + return p.db.Create(webhook).Error +} + +func (p *webhookPers) GetById(id string) (*domain.Webhook, error) { + var webhook domain.Webhook + err := p.db. + Preload("User"). + Preload("Space"). + Where("id = ?", id). + First(&webhook).Error + if err != nil { + return nil, err + } + return &webhook, nil +} + +func (p *webhookPers) GetByUserId(userId string) ([]domain.Webhook, error) { + var webhooks []domain.Webhook + err := p.db. + Preload("Space"). + Where("user_id = ?", userId). + Order("created_at DESC"). + Find(&webhooks).Error + if err != nil { + return nil, err + } + return webhooks, nil +} + +func (p *webhookPers) GetActiveByEvent(event domain.WebhookEvent, spaceId *string) ([]domain.Webhook, error) { + var webhooks []domain.Webhook + query := p.db.Where("active = ?", true) + + // Filter by space if provided, or get global webhooks (no space) + if spaceId != nil { + query = query.Where("space_id = ? OR space_id IS NULL", *spaceId) + } else { + query = query.Where("space_id IS NULL") + } + + err := query.Find(&webhooks).Error + if err != nil { + return nil, err + } + + // Filter by event in application code since Events is JSONB + var filtered []domain.Webhook + for _, w := range webhooks { + if w.HasEvent(event) { + filtered = append(filtered, w) + } + } + + return filtered, nil +} + +func (p *webhookPers) Update(webhook *domain.Webhook) error { + return p.db.Save(webhook).Error +} + +func (p *webhookPers) Delete(id string) error { + return p.db.Where("id = ?", id).Delete(&domain.Webhook{}).Error +} + +func (p *webhookPers) IncrementSuccess(id string) error { + return p.db.Model(&domain.Webhook{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "success_count": gorm.Expr("success_count + 1"), + "last_error": "", + "last_error_at": nil, + }).Error +} + +func (p *webhookPers) RecordFailure(id string, errorMsg string) error { + now := time.Now() + return p.db.Model(&domain.Webhook{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "failure_count": gorm.Expr("failure_count + 1"), + "last_error": errorMsg, + "last_error_at": &now, + }).Error +} + +// WebhookDelivery persistence +type webhookDeliveryPers struct { + db *gorm.DB +} + +func NewWebhookDeliveryPers(db *gorm.DB) *webhookDeliveryPers { + return &webhookDeliveryPers{db: db} +} + +func (p *webhookDeliveryPers) Create(delivery *domain.WebhookDelivery) error { + return p.db.Create(delivery).Error +} + +func (p *webhookDeliveryPers) GetByWebhookId(webhookId string, limit int) ([]domain.WebhookDelivery, error) { + var deliveries []domain.WebhookDelivery + err := p.db. + Where("webhook_id = ?", webhookId). + Order("created_at DESC"). + Limit(limit). + Find(&deliveries).Error + if err != nil { + return nil, err + } + return deliveries, nil +} diff --git a/infrastructure/static/files/index.html b/infrastructure/static/files/index.html new file mode 100644 index 0000000..878bf2a --- /dev/null +++ b/infrastructure/static/files/index.html @@ -0,0 +1,2 @@ +Placeholder file to ensure directory exists.
+While be replaced by the generated application (nexo-ui). \ No newline at end of file diff --git a/infrastructure/static/static.go b/infrastructure/static/static.go new file mode 100644 index 0000000..743eed9 --- /dev/null +++ b/infrastructure/static/static.go @@ -0,0 +1,48 @@ +package static + +import ( + "embed" + "io/fs" + "net/http" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" +) + +var ( + //go:embed files/* + embedDirStatic embed.FS +) + +func NewStatic(f *fiber.App) { + // Serve static assets + fsys, _ := fs.Sub(embedDirStatic, "files/assets") + f.Use("/assets", filesystem.New(filesystem.Config{ + Root: http.FS(fsys), + })) + + // Serve index.html for SPA routes + f.Use(func(c *fiber.Ctx) error { + path := c.Path() + + // Skip API routes + if strings.HasPrefix(path, "/api") { + return c.Next() + } + + // Skip static assets routes + if strings.HasPrefix(path, "/assets") { + return c.Next() + } + + // Serve index.html from the embedded FS for all other routes (SPA routes) + indexFile, err := embedDirStatic.ReadFile("files/index.html") + if err != nil { + return c.Status(fiber.StatusInternalServerError).SendString("Could not find index.html") + } + + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(indexFile) + }) +} diff --git a/interfaces/cli/gen-oapi/genoapi.go b/interfaces/cli/gen-oapi/genoapi.go new file mode 100644 index 0000000..730b457 --- /dev/null +++ b/interfaces/cli/gen-oapi/genoapi.go @@ -0,0 +1,73 @@ +package genoapi + +import ( + "context" + "os" + + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/infrastructure/config" + "github.com/labbs/nexo/infrastructure/http" + "github.com/labbs/nexo/infrastructure/logger" + routes "github.com/labbs/nexo/interfaces/http" + "github.com/urfave/cli/v3" +) + +func NewInstance(version string) *cli.Command { + cfg := &config.Config{} + cfg.Version = version + serverFlags := getFlags(cfg) + + return &cli.Command{ + Name: "genoapi", + Usage: "Generate OpenAPI specification file", + Flags: serverFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runCommand(*cfg) + }, + } +} + +// getFlags returns the list of CLI flags required for the server command. +func getFlags(cfg *config.Config) (list []cli.Flag) { + list = append(list, config.GenericFlags(cfg)...) + list = append(list, config.ServerFlags(cfg)...) + list = append(list, config.LoggerFlags(cfg)...) + list = append(list, config.ExportOapiFlags(cfg)...) + return +} + +func runCommand(cfg config.Config) error { + var err error + + // Initialize dependencies + deps := infrastructure.Deps{ + Config: cfg, + } + + // Initialize logger + deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) + logger := deps.Logger.With().Str("component", "interfaces.cli.genoapi.runcommand").Logger() + + deps.Http, err = http.Configure(deps.Config, deps.Logger, deps.SessionApp, true) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.http.configure").Msg("Failed to configure HTTP server") + return err + } + + routes.SetupRoutes(deps) + + spec, err := deps.Http.FiberOapi.GenerateOpenAPISpecYAML() + if err != nil { + logger.Fatal().Err(err).Str("event", "http.genoapi.generate_openapi_spec_yaml").Msg("Failed to generate OpenAPI spec in YAML format.") + return err + } + + err = os.WriteFile(cfg.ExportOapi.FileName, []byte(spec), 0644) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.genoapi.write_openapi_yaml_file").Msg("Failed to write OpenAPI YAML file.") + return err + } + logger.Info().Str("event", "http.genoapi.openapi_yaml_exported").Str("file", cfg.ExportOapi.FileName).Msg("OpenAPI spec exported in YAML format.") + + return nil +} diff --git a/interfaces/cli/migration/migration.go b/interfaces/cli/migration/migration.go new file mode 100644 index 0000000..b0d365e --- /dev/null +++ b/interfaces/cli/migration/migration.go @@ -0,0 +1,66 @@ +package migration + +import ( + "context" + + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/infrastructure/config" + "github.com/labbs/nexo/infrastructure/database" + "github.com/labbs/nexo/infrastructure/logger" + "github.com/labbs/nexo/infrastructure/migration" + + "github.com/urfave/cli/v3" +) + +// NewInstance creates a new CLI command for running database migrations. +// It's called by the main application to add the "migration" command to the CLI. +func NewInstance(version string) *cli.Command { + cfg := &config.Config{} + cfg.Version = version + migrationFlags := getFlags(cfg) + + return &cli.Command{ + Name: "migration", + Usage: "Start the migration tool", + Flags: migrationFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runMigration(*cfg) + }, + } +} + +// getFlags returns the list of CLI flags required for the migration command. +func getFlags(cfg *config.Config) (list []cli.Flag) { + list = append(list, config.GenericFlags(cfg)...) + list = append(list, config.LoggerFlags(cfg)...) + list = append(list, config.DatabaseFlags(cfg)...) + return +} + +// runMigration initializes the necessary dependencies and runs the database migrations. +func runMigration(cfg config.Config) error { + var err error + + // Initialize dependencies + deps := infrastructure.Deps{ + Config: cfg, + } + + // Initialize logger + deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) + logger := deps.Logger.With().Str("component", "migration.runserver").Logger() + + // Initialize database connection + deps.Database, err = database.Configure(deps.Config, deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "migration.runserver.database.configure").Msg("Failed to configure database connection") + return err + } + + if err := migration.RunMigration(deps.Logger, deps.Database.Db); err != nil { + logger.Error().Err(err).Str("event", "migration.runmigration").Msg("Failed to run migrations") + return err + } + + return nil +} diff --git a/interfaces/cli/server/server.go b/interfaces/cli/server/server.go new file mode 100644 index 0000000..10d38cb --- /dev/null +++ b/interfaces/cli/server/server.go @@ -0,0 +1,150 @@ +package server + +import ( + "context" + "strconv" + + "github.com/labbs/nexo/application/action" + "github.com/labbs/nexo/application/apikey" + "github.com/labbs/nexo/application/auth" + databaseApp "github.com/labbs/nexo/application/database" + "github.com/labbs/nexo/application/document" + "github.com/labbs/nexo/application/drawing" + "github.com/labbs/nexo/application/group" + "github.com/labbs/nexo/application/session" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/application/user" + "github.com/labbs/nexo/application/webhook" + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/infrastructure/config" + "github.com/labbs/nexo/infrastructure/cronscheduler" + "github.com/labbs/nexo/infrastructure/database" + "github.com/labbs/nexo/infrastructure/http" + "github.com/labbs/nexo/infrastructure/jobs" + "github.com/labbs/nexo/infrastructure/logger" + "github.com/labbs/nexo/infrastructure/persistence" + routes "github.com/labbs/nexo/interfaces/http" + + "github.com/urfave/cli/v3" +) + +// NewInstance creates a new CLI command for starting the server. +// It's called by the main application to add the "server" command to the CLI. +func NewInstance(version string) *cli.Command { + cfg := &config.Config{} + cfg.Version = version + serverFlags := getFlags(cfg) + + return &cli.Command{ + Name: "server", + Usage: "Start the Nexo HTTP server", + Flags: serverFlags, + Action: func(ctx context.Context, cmd *cli.Command) error { + return runServer(*cfg) + }, + } +} + +// getFlags returns the list of CLI flags required for the server command. +func getFlags(cfg *config.Config) (list []cli.Flag) { + list = append(list, config.GenericFlags(cfg)...) + list = append(list, config.ServerFlags(cfg)...) + list = append(list, config.LoggerFlags(cfg)...) + list = append(list, config.DatabaseFlags(cfg)...) + list = append(list, config.SessionFlags(cfg)...) + list = append(list, config.RegistrationFlags(cfg)...) + return +} + +// runServer initializes the necessary dependencies and starts the HTTP server. +func runServer(cfg config.Config) error { + var err error + + // Initialize dependencies + deps := infrastructure.Deps{ + Config: cfg, + } + + // Initialize logger + deps.Logger = logger.NewLogger(cfg.Logger.Level, cfg.Logger.Pretty, cfg.Version) + logger := deps.Logger.With().Str("component", "interfaces.cli.http.runserver").Logger() + + // Initialize other cron scheduler (go-cron) + deps.CronScheduler, err = cronscheduler.Configure(deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.cronscheduler.configure").Msg("Failed to configure cron scheduler") + return err + } + + // Initialize database connection (gorm) + deps.Database, err = database.Configure(deps.Config, deps.Logger) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.database.configure").Msg("Failed to configure database connection") + return err + } + + // Initialize application services + userPers := persistence.NewUserPers(deps.Database.Db) + groupPers := persistence.NewGroupPers(deps.Database.Db) + sessionPers := persistence.NewSessionPers(deps.Database.Db) + spacePers := persistence.NewSpacePers(deps.Database.Db) + documentPers := persistence.NewDocumentPers(deps.Database.Db) + permissionPers := persistence.NewPermissionPers(deps.Database.Db) + favoritePers := persistence.NewFavoritePers(deps.Database.Db) + commentPers := persistence.NewCommentPers(deps.Database.Db) + documentVersionPers := persistence.NewDocumentVersionPers(deps.Database.Db) + + apiKeyPers := persistence.NewApiKeyPers(deps.Database.Db) + webhookPers := persistence.NewWebhookPers(deps.Database.Db) + webhookDeliveryPers := persistence.NewWebhookDeliveryPers(deps.Database.Db) + databasePers := persistence.NewDatabasePers(deps.Database.Db) + databaseRowPers := persistence.NewDatabaseRowPers(deps.Database.Db) + drawingPers := persistence.NewDrawingPers(deps.Database.Db) + actionPers := persistence.NewActionPers(deps.Database.Db) + actionRunPers := persistence.NewActionRunPers(deps.Database.Db) + + deps.UserApp = user.NewUserApp(deps.Config, deps.Logger, userPers, groupPers, favoritePers) + deps.SessionApp = session.NewSessionApp(deps.Config, deps.Logger, sessionPers, deps.UserApp) + deps.SpaceApp = space.NewSpaceApp(deps.Config, deps.Logger, spacePers, documentPers, permissionPers) + deps.DocumentApp = document.NewDocumentApp(deps.Config, deps.Logger, documentPers, spacePers, permissionPers, commentPers, documentVersionPers) + deps.AuthApp = auth.NewAuthApp(deps.Config, deps.Logger, deps.UserApp, deps.SessionApp, deps.SpaceApp, deps.DocumentApp) + deps.ApiKeyApp = apikey.NewApiKeyApp(deps.Config, deps.Logger, apiKeyPers) + deps.WebhookApp = webhook.NewWebhookApp(deps.Config, deps.Logger, webhookPers, webhookDeliveryPers) + deps.DatabaseApp = databaseApp.NewDatabaseApp(deps.Config, deps.Logger, databasePers, databaseRowPers, spacePers, permissionPers) + deps.DrawingApp = drawing.NewDrawingApp(deps.Config, deps.Logger, drawingPers, permissionPers, spacePers) + deps.ActionApp = action.NewActionApp(deps.Config, deps.Logger, actionPers, actionRunPers) + deps.GroupApp = group.NewGroupApp(deps.Config, deps.Logger, groupPers, userPers) + + // Initialize HTTP server (fiber + fiberoapi) + deps.Http, err = http.Configure(deps.Config, deps.Logger, deps.SessionApp, true) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.http.configure").Msg("Failed to configure HTTP server") + return err + } + + // Setup cron jobs + configJobs := jobs.Config{ + Logger: deps.Logger, + CronScheduler: deps.CronScheduler, + SessionApp: *deps.SessionApp, + } + + err = configJobs.SetupJobs() + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.jobs.setup").Msg("Failed to setup cron jobs") + return err + } + + // Setup routes + routes.SetupRoutes(deps) + + // Start HTTP server + logger.Info().Str("event", "http.runserver.http.listen").Msgf("Starting HTTP server on port %d", cfg.Server.Port) + err = deps.Http.Fiber.Listen(":" + strconv.Itoa(cfg.Server.Port)) + if err != nil { + logger.Fatal().Err(err).Str("event", "http.runserver.http.listen").Msg("Failed to start HTTP server") + return err + } + + return nil +} diff --git a/interfaces/http/app/router.go b/interfaces/http/app/router.go new file mode 100644 index 0000000..5563011 --- /dev/null +++ b/interfaces/http/app/router.go @@ -0,0 +1,12 @@ +package app + +import ( + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/infrastructure/static" +) + +func SetupRouterApp(deps infrastructure.Deps) { + deps.Logger.Info().Str("component", "http.router.app").Msg("Setting up application routes") + + static.NewStatic(deps.Http.Fiber) +} diff --git a/interfaces/http/dtos/health_response.go b/interfaces/http/dtos/health_response.go new file mode 100644 index 0000000..5d48b6b --- /dev/null +++ b/interfaces/http/dtos/health_response.go @@ -0,0 +1,7 @@ +package dtos + +type HealthResponse struct { + Status string `json:"status"` + Service string `json:"service"` + Version string `json:"version"` +} diff --git a/interfaces/http/router.go b/interfaces/http/router.go new file mode 100644 index 0000000..695d25d --- /dev/null +++ b/interfaces/http/router.go @@ -0,0 +1,17 @@ +package http + +import ( + "github.com/labbs/nexo/infrastructure" + v1 "github.com/labbs/nexo/interfaces/http/v1" +) + +func SetupRoutes(deps infrastructure.Deps) { + logger := deps.Logger.With().Str("component", "http.router").Logger() + logger.Info().Str("event", "setup_routes").Msg("Setting up HTTP routes") + + // Setup system routes (health, metrics, etc.) + setupSystemRoutes(deps) + + // Setup v1 routes + v1.SetupRouterV1(deps) +} diff --git a/interfaces/http/system_routes.go b/interfaces/http/system_routes.go new file mode 100644 index 0000000..bdda10d --- /dev/null +++ b/interfaces/http/system_routes.go @@ -0,0 +1,27 @@ +package http + +import ( + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/interfaces/http/dtos" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" +) + +func setupSystemRoutes(deps infrastructure.Deps) { + // Setup Health route + fiberoapi.Get(deps.Http.FiberOapi, "/api/health", + func(ctx *fiber.Ctx, input struct{}) (*dtos.HealthResponse, *fiberoapi.ErrorResponse) { + return &dtos.HealthResponse{ + Status: "ok", + Service: "nexo", + Version: deps.Config.Version, + }, nil + }, + fiberoapi.OpenAPIOptions{ + Summary: "Health check", + Description: "Returns the health status of the service", + Tags: []string{"Health"}, + }, + ) +} diff --git a/interfaces/http/v1/action/controller.go b/interfaces/http/v1/action/controller.go new file mode 100644 index 0000000..985e45b --- /dev/null +++ b/interfaces/http/v1/action/controller.go @@ -0,0 +1,15 @@ +package action + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/action" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + ActionApp *action.ActionApp +} diff --git a/interfaces/http/v1/action/dtos/action.go b/interfaces/http/v1/action/dtos/action.go new file mode 100644 index 0000000..fd59a07 --- /dev/null +++ b/interfaces/http/v1/action/dtos/action.go @@ -0,0 +1,134 @@ +package dtos + +import "time" + +// Request DTOs + +type EmptyRequest struct{} + +type ActionStep struct { + Type string `json:"type"` + Config map[string]interface{} `json:"config"` +} + +type CreateActionRequest struct { + SpaceId *string `json:"space_id,omitempty"` + DatabaseId *string `json:"database_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + TriggerType string `json:"trigger_type"` + TriggerConfig map[string]interface{} `json:"trigger_config,omitempty"` + Steps []ActionStep `json:"steps"` +} + +type GetActionRequest struct { + ActionId string `path:"action_id"` +} + +type UpdateActionRequest struct { + ActionId string `path:"action_id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + TriggerType *string `json:"trigger_type,omitempty"` + TriggerConfig map[string]interface{} `json:"trigger_config,omitempty"` + Steps *[]ActionStep `json:"steps,omitempty"` + Active *bool `json:"active,omitempty"` +} + +type DeleteActionRequest struct { + ActionId string `path:"action_id"` +} + +type GetRunsRequest struct { + ActionId string `path:"action_id"` + Limit int `query:"limit"` +} + +// Response DTOs + +type MessageResponse struct { + Message string `json:"message"` +} + +type CreateActionResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + TriggerType string `json:"trigger_type"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` +} + +type ActionItem struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + SpaceId *string `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + DatabaseId *string `json:"database_id,omitempty"` + TriggerType string `json:"trigger_type"` + Active bool `json:"active"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` + RunCount int `json:"run_count"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + CreatedAt time.Time `json:"created_at"` +} + +type ListActionsResponse struct { + Actions []ActionItem `json:"actions"` +} + +type GetActionResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + SpaceId *string `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + DatabaseId *string `json:"database_id,omitempty"` + TriggerType string `json:"trigger_type"` + TriggerConfig map[string]interface{} `json:"trigger_config,omitempty"` + Steps []ActionStep `json:"steps"` + Active bool `json:"active"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` + RunCount int `json:"run_count"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type RunItem struct { + Id string `json:"id"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Duration int `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +type GetRunsResponse struct { + Runs []RunItem `json:"runs"` +} + +// Available triggers and steps +type AvailableTriggersResponse struct { + Triggers []TriggerInfo `json:"triggers"` +} + +type TriggerInfo struct { + Type string `json:"type"` + Description string `json:"description"` + Category string `json:"category"` +} + +type AvailableStepsResponse struct { + Steps []StepInfo `json:"steps"` +} + +type StepInfo struct { + Type string `json:"type"` + Description string `json:"description"` + Category string `json:"category"` +} diff --git a/interfaces/http/v1/action/handlers.go b/interfaces/http/v1/action/handlers.go new file mode 100644 index 0000000..7de2026 --- /dev/null +++ b/interfaces/http/v1/action/handlers.go @@ -0,0 +1,328 @@ +package action + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + actionDto "github.com/labbs/nexo/application/action/dto" + "github.com/labbs/nexo/interfaces/http/v1/action/dtos" +) + +func (ctrl *Controller) ListActions(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.ListActionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.ActionApp.ListActions(actionDto.ListActionsInput{ + UserId: authCtx.UserID, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to list actions") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list actions", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListActionsResponse{Actions: make([]dtos.ActionItem, len(result.Actions))} + for i, a := range result.Actions { + resp.Actions[i] = dtos.ActionItem{ + Id: a.Id, + Name: a.Name, + Description: a.Description, + SpaceId: a.SpaceId, + SpaceName: a.SpaceName, + DatabaseId: a.DatabaseId, + TriggerType: a.TriggerType, + Active: a.Active, + LastRunAt: a.LastRunAt, + LastError: a.LastError, + RunCount: a.RunCount, + SuccessCount: a.SuccessCount, + FailureCount: a.FailureCount, + CreatedAt: a.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) CreateAction(ctx *fiber.Ctx, req dtos.CreateActionRequest) (*dtos.CreateActionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + + if req.TriggerType == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Trigger type is required", Type: "BAD_REQUEST"} + } + + if len(req.Steps) == 0 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "At least one step is required", Type: "BAD_REQUEST"} + } + + // Convert steps + steps := make([]actionDto.ActionStep, len(req.Steps)) + for i, s := range req.Steps { + steps[i] = actionDto.ActionStep{ + Type: s.Type, + Config: s.Config, + } + } + + result, err := ctrl.ActionApp.CreateAction(actionDto.CreateActionInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DatabaseId: req.DatabaseId, + Name: req.Name, + Description: req.Description, + TriggerType: req.TriggerType, + TriggerConfig: req.TriggerConfig, + Steps: steps, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to create action") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create action", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateActionResponse{ + Id: result.Id, + Name: result.Name, + Description: result.Description, + TriggerType: result.TriggerType, + Active: result.Active, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) GetAction(ctx *fiber.Ctx, req dtos.GetActionRequest) (*dtos.GetActionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.get").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.ActionApp.GetAction(actionDto.GetActionInput{ + UserId: authCtx.UserID, + ActionId: req.ActionId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Action not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get action") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get action", Type: "INTERNAL_SERVER_ERROR"} + } + + steps := make([]dtos.ActionStep, len(result.Steps)) + for i, s := range result.Steps { + steps[i] = dtos.ActionStep{ + Type: s.Type, + Config: s.Config, + } + } + + return &dtos.GetActionResponse{ + Id: result.Id, + Name: result.Name, + Description: result.Description, + SpaceId: result.SpaceId, + SpaceName: result.SpaceName, + DatabaseId: result.DatabaseId, + TriggerType: result.TriggerType, + TriggerConfig: result.TriggerConfig, + Steps: steps, + Active: result.Active, + LastRunAt: result.LastRunAt, + LastError: result.LastError, + RunCount: result.RunCount, + SuccessCount: result.SuccessCount, + FailureCount: result.FailureCount, + CreatedAt: result.CreatedAt, + UpdatedAt: result.UpdatedAt, + }, nil +} + +func (ctrl *Controller) UpdateAction(ctx *fiber.Ctx, req dtos.UpdateActionRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + var steps *[]actionDto.ActionStep + if req.Steps != nil { + s := make([]actionDto.ActionStep, len(*req.Steps)) + for i, step := range *req.Steps { + s[i] = actionDto.ActionStep{ + Type: step.Type, + Config: step.Config, + } + } + steps = &s + } + + err = ctrl.ActionApp.UpdateAction(actionDto.UpdateActionInput{ + UserId: authCtx.UserID, + ActionId: req.ActionId, + Name: req.Name, + Description: req.Description, + TriggerType: req.TriggerType, + TriggerConfig: req.TriggerConfig, + Steps: steps, + Active: req.Active, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Action not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update action") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update action", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Action updated"}, nil +} + +func (ctrl *Controller) DeleteAction(ctx *fiber.Ctx, req dtos.DeleteActionRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.ActionApp.DeleteAction(actionDto.DeleteActionInput{ + UserId: authCtx.UserID, + ActionId: req.ActionId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Action not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete action") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete action", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Action deleted"}, nil +} + +func (ctrl *Controller) GetRuns(ctx *fiber.Ctx, req dtos.GetRunsRequest) (*dtos.GetRunsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.action.runs").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + limit := req.Limit + if limit <= 0 { + limit = 20 + } + + result, err := ctrl.ActionApp.GetRuns(actionDto.GetRunsInput{ + UserId: authCtx.UserID, + ActionId: req.ActionId, + Limit: limit, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Action not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get runs") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get runs", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.GetRunsResponse{Runs: make([]dtos.RunItem, len(result.Runs))} + for i, r := range result.Runs { + resp.Runs[i] = dtos.RunItem{ + Id: r.Id, + Success: r.Success, + Error: r.Error, + Duration: r.Duration, + CreatedAt: r.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) GetAvailableTriggers(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.AvailableTriggersResponse, *fiberoapi.ErrorResponse) { + triggers := []dtos.TriggerInfo{ + // Document triggers + {Type: "document.created", Description: "When a document is created", Category: "Documents"}, + {Type: "document.updated", Description: "When a document is updated", Category: "Documents"}, + {Type: "document.deleted", Description: "When a document is deleted", Category: "Documents"}, + {Type: "document.moved", Description: "When a document is moved", Category: "Documents"}, + {Type: "document.shared", Description: "When a document is shared", Category: "Documents"}, + // Database triggers + {Type: "row.created", Description: "When a database row is created", Category: "Databases"}, + {Type: "row.updated", Description: "When a database row is updated", Category: "Databases"}, + {Type: "row.deleted", Description: "When a database row is deleted", Category: "Databases"}, + {Type: "property.changed", Description: "When a specific property value changes", Category: "Databases"}, + // Comment triggers + {Type: "comment.created", Description: "When a comment is added", Category: "Comments"}, + {Type: "comment.resolved", Description: "When a comment is resolved", Category: "Comments"}, + // Schedule triggers + {Type: "schedule", Description: "Run on a schedule (cron)", Category: "Schedule"}, + } + + return &dtos.AvailableTriggersResponse{Triggers: triggers}, nil +} + +func (ctrl *Controller) GetAvailableSteps(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.AvailableStepsResponse, *fiberoapi.ErrorResponse) { + steps := []dtos.StepInfo{ + // Notification steps + {Type: "send_email", Description: "Send an email notification", Category: "Notifications"}, + {Type: "send_slack", Description: "Send a Slack message", Category: "Notifications"}, + {Type: "send_webhook", Description: "Send a webhook request", Category: "Notifications"}, + // Document steps + {Type: "create_document", Description: "Create a new document", Category: "Documents"}, + {Type: "update_document", Description: "Update a document", Category: "Documents"}, + {Type: "move_document", Description: "Move a document", Category: "Documents"}, + {Type: "duplicate_document", Description: "Duplicate a document", Category: "Documents"}, + // Database steps + {Type: "create_row", Description: "Create a database row", Category: "Databases"}, + {Type: "update_row", Description: "Update a database row", Category: "Databases"}, + {Type: "delete_row", Description: "Delete a database row", Category: "Databases"}, + {Type: "update_property", Description: "Update a property value", Category: "Databases"}, + // Misc steps + {Type: "add_comment", Description: "Add a comment", Category: "Other"}, + {Type: "assign_user", Description: "Assign a user", Category: "Other"}, + {Type: "set_reminder", Description: "Set a reminder", Category: "Other"}, + } + + return &dtos.AvailableStepsResponse{Steps: steps}, nil +} diff --git a/interfaces/http/v1/action/router.go b/interfaces/http/v1/action/router.go new file mode 100644 index 0000000..191b4ee --- /dev/null +++ b/interfaces/http/v1/action/router.go @@ -0,0 +1,61 @@ +package action + +import fiberoapi "github.com/labbs/fiber-oapi" + +func SetupActionRouter(ctrl Controller) { + fiberoapi.Get(ctrl.FiberOapi, "/", ctrl.ListActions, fiberoapi.OpenAPIOptions{ + Summary: "List actions", + Description: "List all automation actions for the authenticated user", + OperationID: "action.list", + Tags: []string{"Actions"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/", ctrl.CreateAction, fiberoapi.OpenAPIOptions{ + Summary: "Create action", + Description: "Create a new automation action", + OperationID: "action.create", + Tags: []string{"Actions"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/triggers", ctrl.GetAvailableTriggers, fiberoapi.OpenAPIOptions{ + Summary: "Get available triggers", + Description: "List all available trigger types for actions", + OperationID: "action.triggers", + Tags: []string{"Actions"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/steps", ctrl.GetAvailableSteps, fiberoapi.OpenAPIOptions{ + Summary: "Get available steps", + Description: "List all available step types for actions", + OperationID: "action.steps", + Tags: []string{"Actions"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:action_id", ctrl.GetAction, fiberoapi.OpenAPIOptions{ + Summary: "Get action", + Description: "Get a specific action by ID", + OperationID: "action.get", + Tags: []string{"Actions"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:action_id", ctrl.UpdateAction, fiberoapi.OpenAPIOptions{ + Summary: "Update action", + Description: "Update an existing action", + OperationID: "action.update", + Tags: []string{"Actions"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:action_id", ctrl.DeleteAction, fiberoapi.OpenAPIOptions{ + Summary: "Delete action", + Description: "Delete an action", + OperationID: "action.delete", + Tags: []string{"Actions"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:action_id/runs", ctrl.GetRuns, fiberoapi.OpenAPIOptions{ + Summary: "Get action runs", + Description: "Get execution history for an action", + OperationID: "action.runs", + Tags: []string{"Actions"}, + }) +} diff --git a/interfaces/http/v1/admin/dtos/apikeys.go b/interfaces/http/v1/admin/dtos/apikeys.go new file mode 100644 index 0000000..3f5473b --- /dev/null +++ b/interfaces/http/v1/admin/dtos/apikeys.go @@ -0,0 +1,37 @@ +package dtos + +import "time" + +// API Keys list for admin + +type ListAllApiKeysRequest struct { + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type ApiKeyItem struct { + Id string `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` // First 8 chars of the key + UserId string `json:"user_id"` + Username string `json:"username"` + Permissions []string `json:"permissions"` + ExpiresAt time.Time `json:"expires_at,omitempty"` + LastUsedAt time.Time `json:"last_used_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ListAllApiKeysResponse struct { + ApiKeys []ApiKeyItem `json:"api_keys"` + TotalCount int64 `json:"total_count"` +} + +// Revoke API key + +type RevokeApiKeyRequest struct { + ApiKeyId string `path:"apikey_id"` +} + +type RevokeApiKeyResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/admin/dtos/groups.go b/interfaces/http/v1/admin/dtos/groups.go new file mode 100644 index 0000000..c51c907 --- /dev/null +++ b/interfaces/http/v1/admin/dtos/groups.go @@ -0,0 +1,105 @@ +package dtos + +import "time" + +// ListGroupsRequest is the request to list all groups +type ListGroupsRequest struct { + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +// ListGroupsResponse is the response for listing all groups +type ListGroupsResponse struct { + Groups []GroupItem `json:"groups"` + TotalCount int64 `json:"total_count"` +} + +// GroupItem represents a group in list responses +type GroupItem struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Role string `json:"role"` + OwnerId string `json:"owner_id"` + OwnerName string `json:"owner_name,omitempty"` + MemberCount int `json:"member_count"` + Members []MemberItem `json:"members,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// MemberItem represents a group member +type MemberItem struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + AvatarUrl string `json:"avatar_url,omitempty"` +} + +// CreateGroupRequest is the request to create a group +type CreateGroupRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` + Role string `json:"role" validate:"required,oneof=user admin guest"` +} + +// CreateGroupResponse is the response for creating a group +type CreateGroupResponse struct { + Id string `json:"id"` + Message string `json:"message"` +} + +// UpdateGroupRequest is the request to update a group +type UpdateGroupRequest struct { + GroupId string `path:"group_id" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=100"` + Description string `json:"description" validate:"max=500"` + Role string `json:"role" validate:"required,oneof=user admin guest"` +} + +// UpdateGroupResponse is the response for updating a group +type UpdateGroupResponse struct { + Message string `json:"message"` +} + +// DeleteGroupRequest is the request to delete a group +type DeleteGroupRequest struct { + GroupId string `path:"group_id" validate:"required"` +} + +// DeleteGroupResponse is the response for deleting a group +type DeleteGroupResponse struct { + Message string `json:"message"` +} + +// AddGroupMemberRequest is the request to add a member to a group +type AddGroupMemberRequest struct { + GroupId string `path:"group_id" validate:"required"` + UserId string `json:"user_id" validate:"required"` +} + +// AddGroupMemberResponse is the response for adding a member +type AddGroupMemberResponse struct { + Message string `json:"message"` +} + +// RemoveGroupMemberRequest is the request to remove a member from a group +type RemoveGroupMemberRequest struct { + GroupId string `path:"group_id" validate:"required"` + UserId string `path:"user_id" validate:"required"` +} + +// RemoveGroupMemberResponse is the response for removing a member +type RemoveGroupMemberResponse struct { + Message string `json:"message"` +} + +// GetGroupMembersRequest is the request to get group members +type GetGroupMembersRequest struct { + GroupId string `path:"group_id" validate:"required"` +} + +// GetGroupMembersResponse is the response for getting group members +type GetGroupMembersResponse struct { + Members []MemberItem `json:"members"` +} diff --git a/interfaces/http/v1/admin/dtos/spaces.go b/interfaces/http/v1/admin/dtos/spaces.go new file mode 100644 index 0000000..646e12e --- /dev/null +++ b/interfaces/http/v1/admin/dtos/spaces.go @@ -0,0 +1,125 @@ +package dtos + +import "time" + +// Space list for admin + +type ListAllSpacesRequest struct { + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type SpaceItem struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Type string `json:"type"` + OwnerId string `json:"owner_id"` + OwnerName string `json:"owner_name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListAllSpacesResponse struct { + Spaces []SpaceItem `json:"spaces"` + TotalCount int64 `json:"total_count"` +} + +// Create space (admin) + +type AdminCreateSpaceRequest struct { + Name string `json:"name" validate:"required,min=1,max=100"` + Icon string `json:"icon"` + IconColor string `json:"icon_color"` + Type string `json:"type" validate:"required,oneof=public private restricted"` + OwnerId *string `json:"owner_id"` // Optional - can be nil for unowned spaces +} + +type AdminCreateSpaceResponse struct { + Id string `json:"id"` + Message string `json:"message"` +} + +// Update space (admin) + +type AdminUpdateSpaceRequest struct { + SpaceId string `path:"space_id" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=100"` + Icon string `json:"icon"` + IconColor string `json:"icon_color"` + Type string `json:"type" validate:"required,oneof=public private restricted"` + OwnerId *string `json:"owner_id"` +} + +type AdminUpdateSpaceResponse struct { + Message string `json:"message"` +} + +// Delete space (admin) + +type AdminDeleteSpaceRequest struct { + SpaceId string `path:"space_id" validate:"required"` +} + +type AdminDeleteSpaceResponse struct { + Message string `json:"message"` +} + +// Space permissions (admin) + +type AdminListSpacePermissionsRequest struct { + SpaceId string `path:"space_id" validate:"required"` +} + +type SpacePermissionItem struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + GroupId *string `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + Role string `json:"role"` + CreatedAt time.Time `json:"created_at"` +} + +type AdminListSpacePermissionsResponse struct { + Permissions []SpacePermissionItem `json:"permissions"` +} + +type AdminAddSpaceUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required"` + UserId string `json:"user_id" validate:"required"` + Role string `json:"role" validate:"required,oneof=viewer editor admin"` +} + +type AdminAddSpaceUserPermissionResponse struct { + Message string `json:"message"` +} + +type AdminRemoveSpaceUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required"` + UserId string `path:"user_id" validate:"required"` +} + +type AdminRemoveSpaceUserPermissionResponse struct { + Message string `json:"message"` +} + +type AdminAddSpaceGroupPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required"` + GroupId string `json:"group_id" validate:"required"` + Role string `json:"role" validate:"required,oneof=viewer editor admin"` +} + +type AdminAddSpaceGroupPermissionResponse struct { + Message string `json:"message"` +} + +type AdminRemoveSpaceGroupPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required"` + GroupId string `path:"group_id" validate:"required"` +} + +type AdminRemoveSpaceGroupPermissionResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/admin/dtos/users.go b/interfaces/http/v1/admin/dtos/users.go new file mode 100644 index 0000000..0b911f6 --- /dev/null +++ b/interfaces/http/v1/admin/dtos/users.go @@ -0,0 +1,70 @@ +package dtos + +import "time" + +// User list + +type ListUsersRequest struct { + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type UserItem struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + AvatarUrl string `json:"avatar_url"` + Role string `json:"role"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListUsersResponse struct { + Users []UserItem `json:"users"` + TotalCount int64 `json:"total_count"` +} + +// Update user role + +type UpdateUserRoleRequest struct { + UserId string `path:"user_id"` + Role string `json:"role" validate:"required,oneof=user admin guest"` +} + +type UpdateUserRoleResponse struct { + Message string `json:"message"` +} + +// Update user active status + +type UpdateUserActiveRequest struct { + UserId string `path:"user_id"` + Active bool `json:"active"` +} + +type UpdateUserActiveResponse struct { + Message string `json:"message"` +} + +// Delete user + +type DeleteUserRequest struct { + UserId string `path:"user_id"` +} + +type DeleteUserResponse struct { + Message string `json:"message"` +} + +// Invite user + +type InviteUserRequest struct { + Email string `json:"email" validate:"required,email"` + Role string `json:"role" validate:"required,oneof=user admin guest"` +} + +type InviteUserResponse struct { + Message string `json:"message"` + UserId string `json:"user_id,omitempty"` +} diff --git a/interfaces/http/v1/admin/handlers.go b/interfaces/http/v1/admin/handlers.go new file mode 100644 index 0000000..0d7b2a3 --- /dev/null +++ b/interfaces/http/v1/admin/handlers.go @@ -0,0 +1,764 @@ +package admin + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/interfaces/http/v1/admin/dtos" +) + +// checkAdmin verifies the user has admin role +func (ctrl *Controller) checkAdmin(ctx *fiber.Ctx) (*fiberoapi.AuthContext, *fiberoapi.ErrorResponse) { + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + // Get user to check role + user, err := ctrl.UserApp.GetByUserId(struct{ UserId string }{UserId: authCtx.UserID}) + if err != nil { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve user", + Type: "INTERNAL_SERVER_ERROR", + } + } + + if user.User.Role != domain.RoleAdmin { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusForbidden, + Details: "Admin access required", + Type: "FORBIDDEN", + } + } + + return authCtx, nil +} + +// Users + +func (ctrl *Controller) ListUsers(ctx *fiber.Ctx, req dtos.ListUsersRequest) (*dtos.ListUsersResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.list_users").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + // Default pagination + limit := req.Limit + offset := req.Offset + if limit == 0 { + limit = 50 + } + + users, total, err := ctrl.UserApp.GetAllUsers(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get users") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve users", + Type: "INTERNAL_SERVER_ERROR", + } + } + + userItems := make([]dtos.UserItem, len(users)) + for i, u := range users { + userItems[i] = dtos.UserItem{ + Id: u.Id, + Username: u.Username, + Email: u.Email, + AvatarUrl: u.AvatarUrl, + Role: string(u.Role), + Active: u.Active, + CreatedAt: u.CreatedAt, + UpdatedAt: u.UpdatedAt, + } + } + + return &dtos.ListUsersResponse{ + Users: userItems, + TotalCount: total, + }, nil +} + +func (ctrl *Controller) UpdateUserRole(ctx *fiber.Ctx, req dtos.UpdateUserRoleRequest) (*dtos.UpdateUserRoleResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.update_user_role").Logger() + + authCtx, errResp := ctrl.checkAdmin(ctx) + if errResp != nil { + return nil, errResp + } + + // Prevent admin from changing their own role + if req.UserId == authCtx.UserID { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusBadRequest, + Details: "Cannot change your own role", + Type: "CANNOT_CHANGE_OWN_ROLE", + } + } + + role := domain.Role(req.Role) + err := ctrl.UserApp.UpdateRole(req.UserId, role) + if err != nil { + logger.Error().Err(err).Msg("failed to update user role") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to update user role", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.UpdateUserRoleResponse{ + Message: "User role updated successfully", + }, nil +} + +func (ctrl *Controller) UpdateUserActive(ctx *fiber.Ctx, req dtos.UpdateUserActiveRequest) (*dtos.UpdateUserActiveResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.update_user_active").Logger() + + authCtx, errResp := ctrl.checkAdmin(ctx) + if errResp != nil { + return nil, errResp + } + + // Prevent admin from deactivating themselves + if req.UserId == authCtx.UserID { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusBadRequest, + Details: "Cannot deactivate your own account", + Type: "CANNOT_DEACTIVATE_SELF", + } + } + + err := ctrl.UserApp.UpdateActive(req.UserId, req.Active) + if err != nil { + logger.Error().Err(err).Msg("failed to update user active status") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to update user status", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.UpdateUserActiveResponse{ + Message: "User status updated successfully", + }, nil +} + +func (ctrl *Controller) DeleteUser(ctx *fiber.Ctx, req dtos.DeleteUserRequest) (*dtos.DeleteUserResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.delete_user").Logger() + + authCtx, errResp := ctrl.checkAdmin(ctx) + if errResp != nil { + return nil, errResp + } + + // Prevent admin from deleting themselves + if req.UserId == authCtx.UserID { + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusBadRequest, + Details: "Cannot delete your own account", + Type: "CANNOT_DELETE_SELF", + } + } + + err := ctrl.UserApp.DeleteUser(req.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to delete user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to delete user", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.DeleteUserResponse{ + Message: "User deleted successfully", + }, nil +} + +func (ctrl *Controller) InviteUser(ctx *fiber.Ctx, req dtos.InviteUserRequest) (*dtos.InviteUserResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.invite_user").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + // TODO: Implement user invitation (create user with temporary password and send email) + // For now, just return a placeholder response + logger.Info().Str("email", req.Email).Str("role", req.Role).Msg("user invitation requested") + + return &dtos.InviteUserResponse{ + Message: "User invitation feature coming soon", + }, nil +} + +// Spaces + +func (ctrl *Controller) ListAllSpaces(ctx *fiber.Ctx, req dtos.ListAllSpacesRequest) (*dtos.ListAllSpacesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.list_all_spaces").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + // Default pagination + limit := req.Limit + offset := req.Offset + if limit == 0 { + limit = 50 + } + + spaces, total, err := ctrl.SpaceApp.GetAllSpaces(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get spaces") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve spaces", + Type: "INTERNAL_SERVER_ERROR", + } + } + + spaceItems := make([]dtos.SpaceItem, len(spaces)) + for i, s := range spaces { + spaceItems[i] = dtos.SpaceItem{ + Id: s.Id, + Name: s.Name, + Description: "", // Add if available + Icon: s.Icon, + Type: string(s.Type), + CreatedAt: s.CreatedAt, + UpdatedAt: s.UpdatedAt, + } + if s.OwnerId != nil { + spaceItems[i].OwnerId = *s.OwnerId + } + if s.Owner != nil { + spaceItems[i].OwnerName = s.Owner.Username + } + } + + return &dtos.ListAllSpacesResponse{ + Spaces: spaceItems, + TotalCount: total, + }, nil +} + +// API Keys + +func (ctrl *Controller) ListAllApiKeys(ctx *fiber.Ctx, req dtos.ListAllApiKeysRequest) (*dtos.ListAllApiKeysResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.list_all_apikeys").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + // Default pagination + limit := req.Limit + offset := req.Offset + if limit == 0 { + limit = 50 + } + + apiKeys, total, err := ctrl.ApiKeyApp.GetAllApiKeys(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get api keys") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve API keys", + Type: "INTERNAL_SERVER_ERROR", + } + } + + apiKeyItems := make([]dtos.ApiKeyItem, len(apiKeys)) + for i, k := range apiKeys { + // Extract permissions as string array + var permissions []string + if k.Permissions != nil { + if scopes, ok := k.Permissions["scopes"].([]interface{}); ok { + for _, s := range scopes { + if str, ok := s.(string); ok { + permissions = append(permissions, str) + } + } + } + } + + apiKeyItems[i] = dtos.ApiKeyItem{ + Id: k.Id, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + UserId: k.UserId, + Permissions: permissions, + CreatedAt: k.CreatedAt, + } + if k.User.Id != "" { + apiKeyItems[i].Username = k.User.Username + } + if k.ExpiresAt != nil { + apiKeyItems[i].ExpiresAt = *k.ExpiresAt + } + if k.LastUsedAt != nil { + apiKeyItems[i].LastUsedAt = *k.LastUsedAt + } + } + + return &dtos.ListAllApiKeysResponse{ + ApiKeys: apiKeyItems, + TotalCount: total, + }, nil +} + +func (ctrl *Controller) RevokeApiKey(ctx *fiber.Ctx, req dtos.RevokeApiKeyRequest) (*dtos.RevokeApiKeyResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.revoke_apikey").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.ApiKeyApp.AdminDeleteApiKey(req.ApiKeyId) + if err != nil { + logger.Error().Err(err).Msg("failed to revoke api key") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to revoke API key", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.RevokeApiKeyResponse{ + Message: "API key revoked successfully", + }, nil +} + +// Groups + +func (ctrl *Controller) ListGroups(ctx *fiber.Ctx, req dtos.ListGroupsRequest) (*dtos.ListGroupsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.list_groups").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + // Default pagination + limit := req.Limit + offset := req.Offset + if limit == 0 { + limit = 50 + } + + groups, total, err := ctrl.GroupApp.GetAllGroups(limit, offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get groups") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve groups", + Type: "INTERNAL_SERVER_ERROR", + } + } + + groupItems := make([]dtos.GroupItem, len(groups)) + for i, g := range groups { + members := make([]dtos.MemberItem, len(g.Members)) + for j, m := range g.Members { + members[j] = dtos.MemberItem{ + Id: m.Id, + Username: m.Username, + Email: m.Email, + AvatarUrl: m.AvatarUrl, + } + } + + groupItems[i] = dtos.GroupItem{ + Id: g.Id, + Name: g.Name, + Description: g.Description, + Role: string(g.Role), + OwnerId: g.OwnerId, + MemberCount: len(g.Members), + Members: members, + CreatedAt: g.CreatedAt, + UpdatedAt: g.UpdatedAt, + } + if g.OwnerUser.Id != "" { + groupItems[i].OwnerName = g.OwnerUser.Username + } + } + + return &dtos.ListGroupsResponse{ + Groups: groupItems, + TotalCount: total, + }, nil +} + +func (ctrl *Controller) CreateGroup(ctx *fiber.Ctx, req dtos.CreateGroupRequest) (*dtos.CreateGroupResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.create_group").Logger() + + authCtx, errResp := ctrl.checkAdmin(ctx) + if errResp != nil { + return nil, errResp + } + + group, err := ctrl.GroupApp.CreateGroup(req.Name, req.Description, authCtx.UserID, domain.Role(req.Role)) + if err != nil { + logger.Error().Err(err).Msg("failed to create group") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to create group", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.CreateGroupResponse{ + Id: group.Id, + Message: "Group created successfully", + }, nil +} + +func (ctrl *Controller) UpdateGroup(ctx *fiber.Ctx, req dtos.UpdateGroupRequest) (*dtos.UpdateGroupResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.update_group").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.GroupApp.UpdateGroup(req.GroupId, req.Name, req.Description, domain.Role(req.Role)) + if err != nil { + logger.Error().Err(err).Msg("failed to update group") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to update group", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.UpdateGroupResponse{ + Message: "Group updated successfully", + }, nil +} + +func (ctrl *Controller) DeleteGroup(ctx *fiber.Ctx, req dtos.DeleteGroupRequest) (*dtos.DeleteGroupResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.delete_group").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.GroupApp.DeleteGroup(req.GroupId) + if err != nil { + logger.Error().Err(err).Msg("failed to delete group") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to delete group", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.DeleteGroupResponse{ + Message: "Group deleted successfully", + }, nil +} + +func (ctrl *Controller) GetGroupMembers(ctx *fiber.Ctx, req dtos.GetGroupMembersRequest) (*dtos.GetGroupMembersResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.get_group_members").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + members, err := ctrl.GroupApp.GetMembers(req.GroupId) + if err != nil { + logger.Error().Err(err).Msg("failed to get group members") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve group members", + Type: "INTERNAL_SERVER_ERROR", + } + } + + memberItems := make([]dtos.MemberItem, len(members)) + for i, m := range members { + memberItems[i] = dtos.MemberItem{ + Id: m.Id, + Username: m.Username, + Email: m.Email, + AvatarUrl: m.AvatarUrl, + } + } + + return &dtos.GetGroupMembersResponse{ + Members: memberItems, + }, nil +} + +func (ctrl *Controller) AddGroupMember(ctx *fiber.Ctx, req dtos.AddGroupMemberRequest) (*dtos.AddGroupMemberResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.add_group_member").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.GroupApp.AddMember(req.GroupId, req.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to add member to group") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to add member to group", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AddGroupMemberResponse{ + Message: "Member added successfully", + }, nil +} + +func (ctrl *Controller) RemoveGroupMember(ctx *fiber.Ctx, req dtos.RemoveGroupMemberRequest) (*dtos.RemoveGroupMemberResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.remove_group_member").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.GroupApp.RemoveMember(req.GroupId, req.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to remove member from group") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to remove member from group", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.RemoveGroupMemberResponse{ + Message: "Member removed successfully", + }, nil +} + +// Spaces (Admin) + +func (ctrl *Controller) AdminCreateSpace(ctx *fiber.Ctx, req dtos.AdminCreateSpaceRequest) (*dtos.AdminCreateSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.create_space").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + spaceType := domain.SpaceType(req.Type) + space, err := ctrl.SpaceApp.AdminCreateSpace(req.Name, req.Icon, req.IconColor, spaceType, req.OwnerId) + if err != nil { + logger.Error().Err(err).Msg("failed to create space") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to create space", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminCreateSpaceResponse{ + Id: space.Id, + Message: "Space created successfully", + }, nil +} + +func (ctrl *Controller) AdminUpdateSpace(ctx *fiber.Ctx, req dtos.AdminUpdateSpaceRequest) (*dtos.AdminUpdateSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.update_space").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + spaceType := domain.SpaceType(req.Type) + err := ctrl.SpaceApp.AdminUpdateSpace(req.SpaceId, req.Name, req.Icon, req.IconColor, spaceType, req.OwnerId) + if err != nil { + logger.Error().Err(err).Msg("failed to update space") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to update space", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminUpdateSpaceResponse{ + Message: "Space updated successfully", + }, nil +} + +func (ctrl *Controller) AdminDeleteSpace(ctx *fiber.Ctx, req dtos.AdminDeleteSpaceRequest) (*dtos.AdminDeleteSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.delete_space").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.SpaceApp.AdminDeleteSpace(req.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to delete space") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to delete space", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminDeleteSpaceResponse{ + Message: "Space deleted successfully", + }, nil +} + +func (ctrl *Controller) AdminListSpacePermissions(ctx *fiber.Ctx, req dtos.AdminListSpacePermissionsRequest) (*dtos.AdminListSpacePermissionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.list_space_permissions").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + permissions, err := ctrl.SpaceApp.AdminListSpacePermissions(req.SpaceId) + if err != nil { + logger.Error().Err(err).Msg("failed to list space permissions") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to list space permissions", + Type: "INTERNAL_SERVER_ERROR", + } + } + + items := make([]dtos.SpacePermissionItem, len(permissions)) + for i, p := range permissions { + items[i] = dtos.SpacePermissionItem{ + Id: p.Id, + UserId: p.UserId, + GroupId: p.GroupId, + Role: string(p.Role), + CreatedAt: p.CreatedAt, + } + if p.User != nil { + items[i].Username = p.User.Username + } + if p.Group != nil { + items[i].GroupName = p.Group.Name + } + } + + return &dtos.AdminListSpacePermissionsResponse{ + Permissions: items, + }, nil +} + +func (ctrl *Controller) AdminAddSpaceUserPermission(ctx *fiber.Ctx, req dtos.AdminAddSpaceUserPermissionRequest) (*dtos.AdminAddSpaceUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.add_space_user_permission").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + role := domain.PermissionRole(req.Role) + err := ctrl.SpaceApp.AdminAddSpaceUserPermission(req.SpaceId, req.UserId, role) + if err != nil { + logger.Error().Err(err).Msg("failed to add user permission") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to add user permission", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminAddSpaceUserPermissionResponse{ + Message: "User permission added successfully", + }, nil +} + +func (ctrl *Controller) AdminRemoveSpaceUserPermission(ctx *fiber.Ctx, req dtos.AdminRemoveSpaceUserPermissionRequest) (*dtos.AdminRemoveSpaceUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.remove_space_user_permission").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.SpaceApp.AdminRemoveSpaceUserPermission(req.SpaceId, req.UserId) + if err != nil { + logger.Error().Err(err).Msg("failed to remove user permission") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to remove user permission", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminRemoveSpaceUserPermissionResponse{ + Message: "User permission removed successfully", + }, nil +} + +func (ctrl *Controller) AdminAddSpaceGroupPermission(ctx *fiber.Ctx, req dtos.AdminAddSpaceGroupPermissionRequest) (*dtos.AdminAddSpaceGroupPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.add_space_group_permission").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + role := domain.PermissionRole(req.Role) + err := ctrl.SpaceApp.AdminAddSpaceGroupPermission(req.SpaceId, req.GroupId, role) + if err != nil { + logger.Error().Err(err).Msg("failed to add group permission") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to add group permission", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminAddSpaceGroupPermissionResponse{ + Message: "Group permission added successfully", + }, nil +} + +func (ctrl *Controller) AdminRemoveSpaceGroupPermission(ctx *fiber.Ctx, req dtos.AdminRemoveSpaceGroupPermissionRequest) (*dtos.AdminRemoveSpaceGroupPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.admin.remove_space_group_permission").Logger() + + if _, errResp := ctrl.checkAdmin(ctx); errResp != nil { + return nil, errResp + } + + err := ctrl.SpaceApp.AdminRemoveSpaceGroupPermission(req.SpaceId, req.GroupId) + if err != nil { + logger.Error().Err(err).Msg("failed to remove group permission") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to remove group permission", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AdminRemoveSpaceGroupPermissionResponse{ + Message: "Group permission removed successfully", + }, nil +} diff --git a/interfaces/http/v1/admin/router.go b/interfaces/http/v1/admin/router.go new file mode 100644 index 0000000..84016eb --- /dev/null +++ b/interfaces/http/v1/admin/router.go @@ -0,0 +1,169 @@ +package admin + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/apikey" + "github.com/labbs/nexo/application/group" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/application/user" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + UserApp *user.UserApp + SpaceApp *space.SpaceApp + ApiKeyApp *apikey.ApiKeyApp + GroupApp *group.GroupApp +} + +func SetupAdminRouter(controller Controller) { + // Users management + fiberoapi.Get(controller.FiberOapi, "/users", controller.ListUsers, fiberoapi.OpenAPIOptions{ + Summary: "List all users", + Description: "Retrieve a paginated list of all users (admin only)", + OperationID: "admin.listUsers", + Tags: []string{"Admin"}, + }) + fiberoapi.Put(controller.FiberOapi, "/users/:user_id/role", controller.UpdateUserRole, fiberoapi.OpenAPIOptions{ + Summary: "Update user role", + Description: "Change the role of a user (admin only)", + OperationID: "admin.updateUserRole", + Tags: []string{"Admin"}, + }) + fiberoapi.Put(controller.FiberOapi, "/users/:user_id/active", controller.UpdateUserActive, fiberoapi.OpenAPIOptions{ + Summary: "Update user active status", + Description: "Enable or disable a user account (admin only)", + OperationID: "admin.updateUserActive", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/users/:user_id", controller.DeleteUser, fiberoapi.OpenAPIOptions{ + Summary: "Delete user", + Description: "Delete a user account (admin only)", + OperationID: "admin.deleteUser", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/users/invite", controller.InviteUser, fiberoapi.OpenAPIOptions{ + Summary: "Invite user", + Description: "Invite a new user by email (admin only)", + OperationID: "admin.inviteUser", + Tags: []string{"Admin"}, + }) + + // Spaces management + fiberoapi.Get(controller.FiberOapi, "/spaces", controller.ListAllSpaces, fiberoapi.OpenAPIOptions{ + Summary: "List all spaces", + Description: "Retrieve a paginated list of all spaces (admin only)", + OperationID: "admin.listAllSpaces", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/spaces", controller.AdminCreateSpace, fiberoapi.OpenAPIOptions{ + Summary: "Create space", + Description: "Create a new space (admin only)", + OperationID: "admin.createSpace", + Tags: []string{"Admin"}, + }) + fiberoapi.Put(controller.FiberOapi, "/spaces/:space_id", controller.AdminUpdateSpace, fiberoapi.OpenAPIOptions{ + Summary: "Update space", + Description: "Update a space (admin only)", + OperationID: "admin.updateSpace", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/spaces/:space_id", controller.AdminDeleteSpace, fiberoapi.OpenAPIOptions{ + Summary: "Delete space", + Description: "Delete a space (admin only)", + OperationID: "admin.deleteSpace", + Tags: []string{"Admin"}, + }) + fiberoapi.Get(controller.FiberOapi, "/spaces/:space_id/permissions", controller.AdminListSpacePermissions, fiberoapi.OpenAPIOptions{ + Summary: "List space permissions", + Description: "List all permissions for a space (admin only)", + OperationID: "admin.listSpacePermissions", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/spaces/:space_id/permissions/users", controller.AdminAddSpaceUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Add user permission", + Description: "Add a user permission to a space (admin only)", + OperationID: "admin.addSpaceUserPermission", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/spaces/:space_id/permissions/users/:user_id", controller.AdminRemoveSpaceUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Remove user permission", + Description: "Remove a user permission from a space (admin only)", + OperationID: "admin.removeSpaceUserPermission", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/spaces/:space_id/permissions/groups", controller.AdminAddSpaceGroupPermission, fiberoapi.OpenAPIOptions{ + Summary: "Add group permission", + Description: "Add a group permission to a space (admin only)", + OperationID: "admin.addSpaceGroupPermission", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/spaces/:space_id/permissions/groups/:group_id", controller.AdminRemoveSpaceGroupPermission, fiberoapi.OpenAPIOptions{ + Summary: "Remove group permission", + Description: "Remove a group permission from a space (admin only)", + OperationID: "admin.removeSpaceGroupPermission", + Tags: []string{"Admin"}, + }) + + // API Keys management + fiberoapi.Get(controller.FiberOapi, "/apikeys", controller.ListAllApiKeys, fiberoapi.OpenAPIOptions{ + Summary: "List all API keys", + Description: "Retrieve a paginated list of all API keys (admin only)", + OperationID: "admin.listAllApiKeys", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/apikeys/:apikey_id", controller.RevokeApiKey, fiberoapi.OpenAPIOptions{ + Summary: "Revoke API key", + Description: "Revoke an API key (admin only)", + OperationID: "admin.revokeApiKey", + Tags: []string{"Admin"}, + }) + + // Groups management + fiberoapi.Get(controller.FiberOapi, "/groups", controller.ListGroups, fiberoapi.OpenAPIOptions{ + Summary: "List all groups", + Description: "Retrieve a paginated list of all groups (admin only)", + OperationID: "admin.listGroups", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/groups", controller.CreateGroup, fiberoapi.OpenAPIOptions{ + Summary: "Create group", + Description: "Create a new group (admin only)", + OperationID: "admin.createGroup", + Tags: []string{"Admin"}, + }) + fiberoapi.Put(controller.FiberOapi, "/groups/:group_id", controller.UpdateGroup, fiberoapi.OpenAPIOptions{ + Summary: "Update group", + Description: "Update a group's name, description, or role (admin only)", + OperationID: "admin.updateGroup", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/groups/:group_id", controller.DeleteGroup, fiberoapi.OpenAPIOptions{ + Summary: "Delete group", + Description: "Delete a group (admin only)", + OperationID: "admin.deleteGroup", + Tags: []string{"Admin"}, + }) + fiberoapi.Get(controller.FiberOapi, "/groups/:group_id/members", controller.GetGroupMembers, fiberoapi.OpenAPIOptions{ + Summary: "Get group members", + Description: "Retrieve all members of a group (admin only)", + OperationID: "admin.getGroupMembers", + Tags: []string{"Admin"}, + }) + fiberoapi.Post(controller.FiberOapi, "/groups/:group_id/members", controller.AddGroupMember, fiberoapi.OpenAPIOptions{ + Summary: "Add group member", + Description: "Add a user to a group (admin only)", + OperationID: "admin.addGroupMember", + Tags: []string{"Admin"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/groups/:group_id/members/:user_id", controller.RemoveGroupMember, fiberoapi.OpenAPIOptions{ + Summary: "Remove group member", + Description: "Remove a user from a group (admin only)", + OperationID: "admin.removeGroupMember", + Tags: []string{"Admin"}, + }) +} diff --git a/interfaces/http/v1/apikey/dtos/apikey.go b/interfaces/http/v1/apikey/dtos/apikey.go new file mode 100644 index 0000000..e043f1e --- /dev/null +++ b/interfaces/http/v1/apikey/dtos/apikey.go @@ -0,0 +1,63 @@ +package dtos + +import "time" + +// Request DTOs + +type EmptyRequest struct{} + +type CreateApiKeyRequest struct { + Name string `json:"name"` + Scopes []string `json:"scopes"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +type UpdateApiKeyRequest struct { + ApiKeyId string `path:"api_key_id"` + Name *string `json:"name,omitempty"` + Scopes *[]string `json:"scopes,omitempty"` +} + +type DeleteApiKeyRequest struct { + ApiKeyId string `path:"api_key_id"` +} + +// Response DTOs + +type CreateApiKeyResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Key string `json:"key"` // Only returned once + KeyPrefix string `json:"key_prefix"` + Scopes []string `json:"scopes"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ApiKeyItem struct { + Id string `json:"id"` + Name string `json:"name"` + KeyPrefix string `json:"key_prefix"` + Scopes []string `json:"scopes"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type ListApiKeysResponse struct { + ApiKeys []ApiKeyItem `json:"api_keys"` +} + +type MessageResponse struct { + Message string `json:"message"` +} + +// Available scopes for reference +type AvailableScopesResponse struct { + Scopes []ScopeInfo `json:"scopes"` +} + +type ScopeInfo struct { + Scope string `json:"scope"` + Description string `json:"description"` +} diff --git a/interfaces/http/v1/apikey/handlers.go b/interfaces/http/v1/apikey/handlers.go new file mode 100644 index 0000000..f17b7c6 --- /dev/null +++ b/interfaces/http/v1/apikey/handlers.go @@ -0,0 +1,157 @@ +package apikey + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + apikeyDto "github.com/labbs/nexo/application/apikey/dto" + "github.com/labbs/nexo/interfaces/http/v1/apikey/dtos" +) + +func (ctrl *Controller) ListApiKeys(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.ListApiKeysResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.apikey.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.ApiKeyApp.ListApiKeys(apikeyDto.ListApiKeysInput{ + UserId: authCtx.UserID, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to list API keys") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list API keys", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListApiKeysResponse{ApiKeys: make([]dtos.ApiKeyItem, len(result.ApiKeys))} + for i, k := range result.ApiKeys { + resp.ApiKeys[i] = dtos.ApiKeyItem{ + Id: k.Id, + Name: k.Name, + KeyPrefix: k.KeyPrefix, + Scopes: k.Scopes, + LastUsedAt: k.LastUsedAt, + ExpiresAt: k.ExpiresAt, + CreatedAt: k.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) CreateApiKey(ctx *fiber.Ctx, req dtos.CreateApiKeyRequest) (*dtos.CreateApiKeyResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.apikey.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + + if len(req.Scopes) == 0 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "At least one scope is required", Type: "BAD_REQUEST"} + } + + result, err := ctrl.ApiKeyApp.CreateApiKey(apikeyDto.CreateApiKeyInput{ + UserId: authCtx.UserID, + Name: req.Name, + Scopes: req.Scopes, + ExpiresAt: req.ExpiresAt, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to create API key") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create API key", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateApiKeyResponse{ + Id: result.Id, + Name: result.Name, + Key: result.Key, + KeyPrefix: result.KeyPrefix, + Scopes: result.Scopes, + ExpiresAt: result.ExpiresAt, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) UpdateApiKey(ctx *fiber.Ctx, req dtos.UpdateApiKeyRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.apikey.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.ApiKeyApp.UpdateApiKey(apikeyDto.UpdateApiKeyInput{ + UserId: authCtx.UserID, + ApiKeyId: req.ApiKeyId, + Name: req.Name, + Scopes: req.Scopes, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "API key not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update API key") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update API key", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "API key updated"}, nil +} + +func (ctrl *Controller) DeleteApiKey(ctx *fiber.Ctx, req dtos.DeleteApiKeyRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.apikey.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.ApiKeyApp.DeleteApiKey(apikeyDto.DeleteApiKeyInput{ + UserId: authCtx.UserID, + ApiKeyId: req.ApiKeyId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "API key not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete API key") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete API key", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "API key deleted"}, nil +} + +func (ctrl *Controller) GetAvailableScopes(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.AvailableScopesResponse, *fiberoapi.ErrorResponse) { + scopes := []dtos.ScopeInfo{ + {Scope: "read:documents", Description: "Read access to documents"}, + {Scope: "write:documents", Description: "Write access to documents (create, update, delete)"}, + {Scope: "read:spaces", Description: "Read access to spaces"}, + {Scope: "write:spaces", Description: "Write access to spaces"}, + {Scope: "read:comments", Description: "Read access to comments"}, + {Scope: "write:comments", Description: "Write access to comments"}, + {Scope: "manage:webhooks", Description: "Manage webhooks"}, + {Scope: "manage:databases", Description: "Manage databases"}, + } + + return &dtos.AvailableScopesResponse{Scopes: scopes}, nil +} diff --git a/interfaces/http/v1/apikey/router.go b/interfaces/http/v1/apikey/router.go new file mode 100644 index 0000000..7b6ff2b --- /dev/null +++ b/interfaces/http/v1/apikey/router.go @@ -0,0 +1,48 @@ +package apikey + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/apikey" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + ApiKeyApp *apikey.ApiKeyApp +} + +func SetupApiKeyRouter(controller Controller) { + fiberoapi.Get(controller.FiberOapi, "/", controller.ListApiKeys, fiberoapi.OpenAPIOptions{ + Summary: "List API keys", + Description: "List all API keys for the current user", + OperationID: "apikey.list", + Tags: []string{"API Keys"}, + }) + fiberoapi.Post(controller.FiberOapi, "/", controller.CreateApiKey, fiberoapi.OpenAPIOptions{ + Summary: "Create API key", + Description: "Create a new API key. The key is only shown once.", + OperationID: "apikey.create", + Tags: []string{"API Keys"}, + }) + fiberoapi.Put(controller.FiberOapi, "/:api_key_id", controller.UpdateApiKey, fiberoapi.OpenAPIOptions{ + Summary: "Update API key", + Description: "Update an API key's name or scopes", + OperationID: "apikey.update", + Tags: []string{"API Keys"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/:api_key_id", controller.DeleteApiKey, fiberoapi.OpenAPIOptions{ + Summary: "Delete API key", + Description: "Revoke and delete an API key", + OperationID: "apikey.delete", + Tags: []string{"API Keys"}, + }) + fiberoapi.Get(controller.FiberOapi, "/scopes", controller.GetAvailableScopes, fiberoapi.OpenAPIOptions{ + Summary: "Get available scopes", + Description: "List all available permission scopes for API keys", + OperationID: "apikey.scopes", + Tags: []string{"API Keys"}, + }) +} diff --git a/interfaces/http/v1/auth/dtos/login_request.go b/interfaces/http/v1/auth/dtos/login_request.go new file mode 100644 index 0000000..f5d86c0 --- /dev/null +++ b/interfaces/http/v1/auth/dtos/login_request.go @@ -0,0 +1,10 @@ +package dtos + +type LoginRequest struct { + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required"` +} + +type LoginResponse struct { + Token string `json:"token"` +} diff --git a/interfaces/http/v1/auth/dtos/logout_request.go b/interfaces/http/v1/auth/dtos/logout_request.go new file mode 100644 index 0000000..c84882f --- /dev/null +++ b/interfaces/http/v1/auth/dtos/logout_request.go @@ -0,0 +1,5 @@ +package dtos + +type LogoutResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/auth/dtos/register_request.go b/interfaces/http/v1/auth/dtos/register_request.go new file mode 100644 index 0000000..f13f19d --- /dev/null +++ b/interfaces/http/v1/auth/dtos/register_request.go @@ -0,0 +1,11 @@ +package dtos + +type RegisterRequest struct { + Email string `json:"email" validate:"required,email"` + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type RegisterResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/auth/handlers.go b/interfaces/http/v1/auth/handlers.go new file mode 100644 index 0000000..7b17dc3 --- /dev/null +++ b/interfaces/http/v1/auth/handlers.go @@ -0,0 +1,82 @@ +package auth + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + authDto "github.com/labbs/nexo/application/auth/dto" + "github.com/labbs/nexo/interfaces/http/v1/auth/dtos" +) + +func (ctrl Controller) Login(ctx *fiber.Ctx, req dtos.LoginRequest) (*dtos.LoginResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.login").Logger() + + resp, err := ctrl.AuthApp.Authenticate(authDto.AuthenticateInput{ + Email: req.Email, + Password: req.Password, + Context: ctx, + }) + if err != nil { + logger.Error().Err(err).Str("email", req.Email).Msg("failed to authenticate user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: err.Error(), + Type: "AUTHENTICATION_FAILED", + } + } + return &dtos.LoginResponse{Token: resp.Token}, nil +} + +func (ctrl Controller) Logout(ctx *fiber.Ctx, input struct{}) (*dtos.LogoutResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.logout").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + err = ctrl.AuthApp.Logout(authDto.LogoutInput{SessionId: authCtx.Claims["session_id"].(string)}) + if err != nil { + logger.Error().Err(err).Str("session_id", authCtx.Claims["session_id"].(string)).Msg("failed to logout user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: err.Error(), + Type: "LOGOUT_FAILED", + } + } + + return &dtos.LogoutResponse{ + Message: "Logged out successfully", + }, nil +} + +func (ctrl Controller) Register(ctx *fiber.Ctx, req dtos.RegisterRequest) (*dtos.RegisterResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.auth.register").Logger() + + err := ctrl.AuthApp.Register(authDto.RegisterInput{ + Username: req.Username, + Email: req.Email, + Password: req.Password, + }) + if err != nil { + logger.Error().Err(err).Str("email", req.Email).Msg("failed to register user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusBadRequest, + Details: err.Error(), + Type: "REGISTRATION_FAILED", + } + } + + return &dtos.RegisterResponse{ + Message: "User registered successfully", + }, nil +} + +//TODO: implement password reset, email verification, ... diff --git a/interfaces/http/v1/auth/router.go b/interfaces/http/v1/auth/router.go new file mode 100644 index 0000000..23c6eaa --- /dev/null +++ b/interfaces/http/v1/auth/router.go @@ -0,0 +1,40 @@ +package auth + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/auth" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + AuthApp *auth.AuthApp +} + +func SetupAuthRouter(controller Controller) { + fiberoapi.Post(controller.FiberOapi, "/login", controller.Login, fiberoapi.OpenAPIOptions{ + Summary: "Login user", + Description: "Authenticate user and return a token", + OperationID: "auth.login", + Tags: []string{"Auth"}, + Security: "disabled", + }) + + fiberoapi.Get(controller.FiberOapi, "/logout", controller.Logout, fiberoapi.OpenAPIOptions{ + Summary: "Logout user", + Description: "Invalidate user session", + OperationID: "auth.logout", + Tags: []string{"Auth"}, + }) + + fiberoapi.Post(controller.FiberOapi, "/register", controller.Register, fiberoapi.OpenAPIOptions{ + Summary: "Register user", + Description: "Register a new user", + OperationID: "auth.register", + Tags: []string{"Auth"}, + Security: "disabled", + }) +} diff --git a/interfaces/http/v1/database/controller.go b/interfaces/http/v1/database/controller.go new file mode 100644 index 0000000..804df6b --- /dev/null +++ b/interfaces/http/v1/database/controller.go @@ -0,0 +1,15 @@ +package database + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/database" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + DatabaseApp *database.DatabaseApp +} diff --git a/interfaces/http/v1/database/dtos/database.go b/interfaces/http/v1/database/dtos/database.go new file mode 100644 index 0000000..8836c19 --- /dev/null +++ b/interfaces/http/v1/database/dtos/database.go @@ -0,0 +1,286 @@ +package dtos + +import "time" + +// Request DTOs + +type EmptyRequest struct{} + +type PropertySchema struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Options map[string]interface{} `json:"options,omitempty"` +} + +type ViewConfig struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Filter map[string]interface{} `json:"filter,omitempty"` + Sort []SortConfig `json:"sort,omitempty"` + Columns []string `json:"columns,omitempty"` + HiddenColumns []string `json:"hidden_columns,omitempty"` + GroupBy string `json:"group_by,omitempty"` +} + +type SortConfig struct { + PropertyId string `json:"property_id"` + Direction string `json:"direction"` +} + +type CreateDatabaseRequest struct { + SpaceId string `json:"space_id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Schema []PropertySchema `json:"schema"` + Type string `json:"type,omitempty"` // "spreadsheet" or "document", defaults to "spreadsheet" +} + +type ListDatabasesRequest struct { + SpaceId string `query:"space_id"` +} + +type GetDatabaseRequest struct { + DatabaseId string `path:"database_id"` +} + +type UpdateDatabaseRequest struct { + DatabaseId string `path:"database_id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Icon *string `json:"icon,omitempty"` + Schema *[]PropertySchema `json:"schema,omitempty"` + DefaultView *string `json:"default_view,omitempty"` +} + +type DeleteDatabaseRequest struct { + DatabaseId string `path:"database_id"` +} + +// Row requests +type CreateRowRequest struct { + DatabaseId string `path:"database_id"` + Properties map[string]interface{} `json:"properties"` + Content map[string]interface{} `json:"content,omitempty"` + ShowInSidebar bool `json:"show_in_sidebar,omitempty"` +} + +type ListRowsRequest struct { + DatabaseId string `path:"database_id"` + ViewId string `query:"view_id"` + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type GetRowRequest struct { + DatabaseId string `path:"database_id"` + RowId string `path:"row_id"` +} + +type UpdateRowRequest struct { + DatabaseId string `path:"database_id"` + RowId string `path:"row_id"` + Properties map[string]interface{} `json:"properties,omitempty"` + Content map[string]interface{} `json:"content,omitempty"` + ShowInSidebar *bool `json:"show_in_sidebar,omitempty"` +} + +type DeleteRowRequest struct { + DatabaseId string `path:"database_id"` + RowId string `path:"row_id"` +} + +type BulkDeleteRowsRequest struct { + DatabaseId string `path:"database_id"` + RowIds []string `json:"row_ids"` +} + +// View requests +type CreateViewRequest struct { + DatabaseId string `path:"database_id"` + Name string `json:"name" validate:"required"` + Type string `json:"type" validate:"required,oneof=table board calendar gallery list timeline"` + Filter map[string]interface{} `json:"filter,omitempty"` + Sort []SortConfig `json:"sort,omitempty"` + Columns []string `json:"columns,omitempty"` +} + +type UpdateViewRequest struct { + DatabaseId string `path:"database_id"` + ViewId string `path:"view_id"` + Name *string `json:"name,omitempty"` + Type *string `json:"type,omitempty"` + Filter map[string]interface{} `json:"filter,omitempty"` + Sort []SortConfig `json:"sort,omitempty"` + Columns []string `json:"columns,omitempty"` + HiddenColumns []string `json:"hidden_columns,omitempty"` + GroupBy *string `json:"group_by,omitempty"` +} + +type DeleteViewRequest struct { + DatabaseId string `path:"database_id"` + ViewId string `path:"view_id"` +} + +// Move database +type MoveDatabaseRequest struct { + DatabaseId string `path:"database_id"` + DocumentId *string `json:"document_id"` +} + +type MoveDatabaseResponse struct { + Id string `json:"id"` + DocumentId *string `json:"document_id,omitempty"` +} + +// Filter rule for querying rows +type FilterRule struct { + Property string `json:"property"` + Condition string `json:"condition"` // eq, neq, gt, lt, gte, lte, contains, is_empty, is_not_empty + Value interface{} `json:"value,omitempty"` +} + +type FilterConfig struct { + And []FilterRule `json:"and,omitempty"` + Or []FilterRule `json:"or,omitempty"` +} + +// Response DTOs + +type MessageResponse struct { + Message string `json:"message"` +} + +// UserInfo contains basic user information for display +type UserInfo struct { + Id string `json:"id"` + Username string `json:"username"` + AvatarUrl string `json:"avatar_url,omitempty"` +} + +type CreateDatabaseResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Schema []PropertySchema `json:"schema"` + DefaultView string `json:"default_view"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` +} + +type DatabaseItem struct { + Id string `json:"id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Type string `json:"type"` + RowCount int64 `json:"row_count"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListDatabasesResponse struct { + Databases []DatabaseItem `json:"databases"` +} + +type GetDatabaseResponse struct { + Id string `json:"id"` + SpaceId string `json:"space_id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Schema []PropertySchema `json:"schema"` + Views []ViewConfig `json:"views"` + DefaultView string `json:"default_view"` + Type string `json:"type"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CreateRowResponse struct { + Id string `json:"id"` + Properties map[string]interface{} `json:"properties"` + CreatedAt time.Time `json:"created_at"` +} + +type RowItem struct { + Id string `json:"id"` + Properties map[string]interface{} `json:"properties"` + Content map[string]interface{} `json:"content,omitempty"` + ShowInSidebar bool `json:"show_in_sidebar"` + CreatedBy string `json:"created_by"` + CreatedByUser *UserInfo `json:"created_by_user,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + UpdatedByUser *UserInfo `json:"updated_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListRowsResponse struct { + Rows []RowItem `json:"rows"` + TotalCount int64 `json:"total_count"` +} + +type GetRowResponse struct { + Id string `json:"id"` + DatabaseId string `json:"database_id"` + Properties map[string]interface{} `json:"properties"` + Content map[string]interface{} `json:"content,omitempty"` + ShowInSidebar bool `json:"show_in_sidebar"` + CreatedBy string `json:"created_by"` + CreatedByUser *UserInfo `json:"created_by_user,omitempty"` + UpdatedBy string `json:"updated_by,omitempty"` + UpdatedByUser *UserInfo `json:"updated_by_user,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// View responses +type CreateViewResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Filter map[string]interface{} `json:"filter,omitempty"` + Sort []SortConfig `json:"sort,omitempty"` + Columns []string `json:"columns,omitempty"` +} + +// Available property types +type AvailableTypesResponse struct { + Types []TypeInfo `json:"types"` +} + +type TypeInfo struct { + Type string `json:"type"` + Description string `json:"description"` +} + +// Search +type SearchDatabasesRequest struct { + Query string `query:"q"` + SpaceId *string `query:"space_id"` + Limit int `query:"limit"` +} + +type SearchDatabaseResultItem struct { + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon,omitempty"` + Type string `json:"type"` + SpaceId string `json:"space_id"` + SpaceName string `json:"space_name"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SearchDatabasesResponse struct { + Results []SearchDatabaseResultItem `json:"results"` +} diff --git a/interfaces/http/v1/database/dtos/permissions.go b/interfaces/http/v1/database/dtos/permissions.go new file mode 100644 index 0000000..23c0f70 --- /dev/null +++ b/interfaces/http/v1/database/dtos/permissions.go @@ -0,0 +1,43 @@ +package dtos + +// Permission item +type DatabasePermissionItem struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + GroupId *string `json:"group_id,omitempty"` + GroupName *string `json:"group_name,omitempty"` + Role string `json:"role"` +} + +// List permissions request/response +type ListDatabasePermissionsRequest struct { + DatabaseId string `path:"database_id"` +} + +type ListDatabasePermissionsResponse struct { + Permissions []DatabasePermissionItem `json:"permissions"` +} + +// Upsert permission request +type UpsertDatabasePermissionRequest struct { + DatabaseId string `path:"database_id"` + UserId *string `json:"user_id,omitempty"` + GroupId *string `json:"group_id,omitempty"` + Role string `json:"role"` +} + +type UpsertDatabasePermissionResponse struct { + Success bool `json:"success"` +} + +// Delete permission request +type DeleteDatabasePermissionRequest struct { + DatabaseId string `path:"database_id"` + UserId *string `query:"user_id"` + GroupId *string `query:"group_id"` +} + +type DeleteDatabasePermissionResponse struct { + Success bool `json:"success"` +} diff --git a/interfaces/http/v1/database/handlers.go b/interfaces/http/v1/database/handlers.go new file mode 100644 index 0000000..c480bcc --- /dev/null +++ b/interfaces/http/v1/database/handlers.go @@ -0,0 +1,767 @@ +package database + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + databaseDto "github.com/labbs/nexo/application/database/dto" + "github.com/labbs/nexo/interfaces/http/v1/database/dtos" +) + +func (ctrl *Controller) CreateDatabase(ctx *fiber.Ctx, req dtos.CreateDatabaseRequest) (*dtos.CreateDatabaseResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + + if req.SpaceId == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Space ID is required", Type: "BAD_REQUEST"} + } + + // Convert schema + schema := make([]databaseDto.PropertySchema, len(req.Schema)) + for i, s := range req.Schema { + schema[i] = databaseDto.PropertySchema{ + Id: s.Id, + Name: s.Name, + Type: s.Type, + Options: s.Options, + } + } + + result, err := ctrl.DatabaseApp.CreateDatabase(databaseDto.CreateDatabaseInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + Name: req.Name, + Description: req.Description, + Icon: req.Icon, + Schema: schema, + Type: req.Type, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to create database") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create database", Type: "INTERNAL_SERVER_ERROR"} + } + + respSchema := make([]dtos.PropertySchema, len(result.Schema)) + for i, s := range result.Schema { + respSchema[i] = dtos.PropertySchema{ + Id: s.Id, + Name: s.Name, + Type: s.Type, + Options: s.Options, + } + } + + return &dtos.CreateDatabaseResponse{ + Id: result.Id, + Name: result.Name, + Description: result.Description, + Icon: result.Icon, + Schema: respSchema, + DefaultView: result.DefaultView, + Type: result.Type, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) ListDatabases(ctx *fiber.Ctx, req dtos.ListDatabasesRequest) (*dtos.ListDatabasesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.SpaceId == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Space ID is required", Type: "BAD_REQUEST"} + } + + result, err := ctrl.DatabaseApp.ListDatabases(databaseDto.ListDatabasesInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to list databases") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list databases", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListDatabasesResponse{Databases: make([]dtos.DatabaseItem, len(result.Databases))} + for i, db := range result.Databases { + resp.Databases[i] = dtos.DatabaseItem{ + Id: db.Id, + DocumentId: db.DocumentId, + Name: db.Name, + Description: db.Description, + Icon: db.Icon, + Type: db.Type, + RowCount: db.RowCount, + CreatedBy: db.CreatedBy, + CreatedAt: db.CreatedAt, + UpdatedAt: db.UpdatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) GetDatabase(ctx *fiber.Ctx, req dtos.GetDatabaseRequest) (*dtos.GetDatabaseResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.get").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.GetDatabase(databaseDto.GetDatabaseInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get database") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get database", Type: "INTERNAL_SERVER_ERROR"} + } + + schema := make([]dtos.PropertySchema, len(result.Schema)) + for i, s := range result.Schema { + schema[i] = dtos.PropertySchema{ + Id: s.Id, + Name: s.Name, + Type: s.Type, + Options: s.Options, + } + } + + views := make([]dtos.ViewConfig, len(result.Views)) + for i, v := range result.Views { + sort := make([]dtos.SortConfig, len(v.Sort)) + for j, s := range v.Sort { + sort[j] = dtos.SortConfig{ + PropertyId: s.PropertyId, + Direction: s.Direction, + } + } + views[i] = dtos.ViewConfig{ + Id: v.Id, + Name: v.Name, + Type: v.Type, + Filter: v.Filter, + Sort: sort, + Columns: v.Columns, + HiddenColumns: v.HiddenColumns, + } + } + + return &dtos.GetDatabaseResponse{ + Id: result.Id, + SpaceId: result.SpaceId, + DocumentId: result.DocumentId, + Name: result.Name, + Description: result.Description, + Icon: result.Icon, + Schema: schema, + Views: views, + DefaultView: result.DefaultView, + Type: result.Type, + CreatedBy: result.CreatedBy, + CreatedAt: result.CreatedAt, + UpdatedAt: result.UpdatedAt, + }, nil +} + +func (ctrl *Controller) UpdateDatabase(ctx *fiber.Ctx, req dtos.UpdateDatabaseRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + var schema *[]databaseDto.PropertySchema + if req.Schema != nil { + s := make([]databaseDto.PropertySchema, len(*req.Schema)) + for i, ps := range *req.Schema { + s[i] = databaseDto.PropertySchema{ + Id: ps.Id, + Name: ps.Name, + Type: ps.Type, + Options: ps.Options, + } + } + schema = &s + } + + err = ctrl.DatabaseApp.UpdateDatabase(databaseDto.UpdateDatabaseInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + Name: req.Name, + Description: req.Description, + Icon: req.Icon, + Schema: schema, + DefaultView: req.DefaultView, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update database") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update database", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Database updated"}, nil +} + +func (ctrl *Controller) DeleteDatabase(ctx *fiber.Ctx, req dtos.DeleteDatabaseRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.DeleteDatabase(databaseDto.DeleteDatabaseInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete database") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete database", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Database deleted"}, nil +} + +func (ctrl *Controller) GetAvailableTypes(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.AvailableTypesResponse, *fiberoapi.ErrorResponse) { + types := []dtos.TypeInfo{ + {Type: "title", Description: "Title property (required, one per database)"}, + {Type: "text", Description: "Plain text"}, + {Type: "number", Description: "Number with optional format"}, + {Type: "currency", Description: "Currency with symbol and locale formatting"}, + {Type: "select", Description: "Single select from options"}, + {Type: "multi_select", Description: "Multiple select from options"}, + {Type: "date", Description: "Date with optional time"}, + {Type: "checkbox", Description: "Boolean checkbox"}, + {Type: "url", Description: "URL link"}, + {Type: "email", Description: "Email address"}, + {Type: "phone", Description: "Phone number"}, + {Type: "image", Description: "Image URL with preview"}, + {Type: "relation", Description: "Relation to another database"}, + {Type: "rollup", Description: "Rollup from relation"}, + {Type: "formula", Description: "Calculated formula"}, + {Type: "created_time", Description: "Auto-populated creation time"}, + {Type: "updated_time", Description: "Auto-populated update time"}, + {Type: "created_by", Description: "Auto-populated creator"}, + {Type: "updated_by", Description: "Auto-populated last editor"}, + {Type: "files", Description: "File attachments"}, + {Type: "person", Description: "Person/user reference"}, + } + + return &dtos.AvailableTypesResponse{Types: types}, nil +} + +// View handlers + +func (ctrl *Controller) CreateView(ctx *fiber.Ctx, req dtos.CreateViewRequest) (*dtos.CreateViewResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.view.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + if req.Type == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Type is required", Type: "BAD_REQUEST"} + } + + // Convert sort config + sort := make([]databaseDto.SortConfig, len(req.Sort)) + for i, s := range req.Sort { + sort[i] = databaseDto.SortConfig{ + PropertyId: s.PropertyId, + Direction: s.Direction, + } + } + + result, err := ctrl.DatabaseApp.CreateView(databaseDto.CreateViewInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + Name: req.Name, + Type: req.Type, + Filter: req.Filter, + Sort: sort, + Columns: req.Columns, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to create view") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create view", Type: "INTERNAL_SERVER_ERROR"} + } + + respSort := make([]dtos.SortConfig, len(result.Sort)) + for i, s := range result.Sort { + respSort[i] = dtos.SortConfig{ + PropertyId: s.PropertyId, + Direction: s.Direction, + } + } + + return &dtos.CreateViewResponse{ + Id: result.Id, + Name: result.Name, + Type: result.Type, + Filter: result.Filter, + Sort: respSort, + Columns: result.Columns, + }, nil +} + +func (ctrl *Controller) UpdateView(ctx *fiber.Ctx, req dtos.UpdateViewRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.view.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + // Convert sort config + var sort []databaseDto.SortConfig + if len(req.Sort) > 0 { + sort = make([]databaseDto.SortConfig, len(req.Sort)) + for i, s := range req.Sort { + sort[i] = databaseDto.SortConfig{ + PropertyId: s.PropertyId, + Direction: s.Direction, + } + } + } + + err = ctrl.DatabaseApp.UpdateView(databaseDto.UpdateViewInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + ViewId: req.ViewId, + Name: req.Name, + Type: req.Type, + Filter: req.Filter, + Sort: sort, + Columns: req.Columns, + HiddenColumns: req.HiddenColumns, + GroupBy: req.GroupBy, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "View not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update view") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update view", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "View updated"}, nil +} + +func (ctrl *Controller) DeleteView(ctx *fiber.Ctx, req dtos.DeleteViewRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.view.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.DeleteView(databaseDto.DeleteViewInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + ViewId: req.ViewId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "View not found", Type: "NOT_FOUND"} + } + if strings.Contains(err.Error(), "cannot delete last view") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Cannot delete the last view", Type: "BAD_REQUEST"} + } + logger.Error().Err(err).Msg("failed to delete view") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete view", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "View deleted"}, nil +} + +// Row handlers + +func (ctrl *Controller) CreateRow(ctx *fiber.Ctx, req dtos.CreateRowRequest) (*dtos.CreateRowResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.CreateRow(databaseDto.CreateRowInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + Properties: req.Properties, + Content: req.Content, + ShowInSidebar: req.ShowInSidebar, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to create row") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create row", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateRowResponse{ + Id: result.Id, + Properties: result.Properties, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) ListRows(ctx *fiber.Ctx, req dtos.ListRowsRequest) (*dtos.ListRowsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.ListRows(databaseDto.ListRowsInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + ViewId: req.ViewId, + Limit: req.Limit, + Offset: req.Offset, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to list rows") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list rows", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListRowsResponse{ + Rows: make([]dtos.RowItem, len(result.Rows)), + TotalCount: result.TotalCount, + } + for i, row := range result.Rows { + rowItem := dtos.RowItem{ + Id: row.Id, + Properties: row.Properties, + Content: row.Content, + ShowInSidebar: row.ShowInSidebar, + CreatedBy: row.CreatedBy, + UpdatedBy: row.UpdatedBy, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + } + if row.CreatedByUser != nil { + rowItem.CreatedByUser = &dtos.UserInfo{ + Id: row.CreatedByUser.Id, + Username: row.CreatedByUser.Username, + AvatarUrl: row.CreatedByUser.AvatarUrl, + } + } + if row.UpdatedByUser != nil { + rowItem.UpdatedByUser = &dtos.UserInfo{ + Id: row.UpdatedByUser.Id, + Username: row.UpdatedByUser.Username, + AvatarUrl: row.UpdatedByUser.AvatarUrl, + } + } + resp.Rows[i] = rowItem + } + + return resp, nil +} + +func (ctrl *Controller) GetRow(ctx *fiber.Ctx, req dtos.GetRowRequest) (*dtos.GetRowResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.get").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.GetRow(databaseDto.GetRowInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + RowId: req.RowId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Row not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get row") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get row", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.GetRowResponse{ + Id: result.Id, + DatabaseId: result.DatabaseId, + Properties: result.Properties, + Content: result.Content, + ShowInSidebar: result.ShowInSidebar, + CreatedBy: result.CreatedBy, + UpdatedBy: result.UpdatedBy, + CreatedAt: result.CreatedAt, + UpdatedAt: result.UpdatedAt, + } + if result.CreatedByUser != nil { + resp.CreatedByUser = &dtos.UserInfo{ + Id: result.CreatedByUser.Id, + Username: result.CreatedByUser.Username, + AvatarUrl: result.CreatedByUser.AvatarUrl, + } + } + if result.UpdatedByUser != nil { + resp.UpdatedByUser = &dtos.UserInfo{ + Id: result.UpdatedByUser.Id, + Username: result.UpdatedByUser.Username, + AvatarUrl: result.UpdatedByUser.AvatarUrl, + } + } + return resp, nil +} + +func (ctrl *Controller) UpdateRow(ctx *fiber.Ctx, req dtos.UpdateRowRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.UpdateRow(databaseDto.UpdateRowInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + RowId: req.RowId, + Properties: req.Properties, + Content: req.Content, + ShowInSidebar: req.ShowInSidebar, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Row not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update row") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update row", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Row updated"}, nil +} + +func (ctrl *Controller) DeleteRow(ctx *fiber.Ctx, req dtos.DeleteRowRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.DeleteRow(databaseDto.DeleteRowInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + RowId: req.RowId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Row not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete row") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete row", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Row deleted"}, nil +} + +func (ctrl *Controller) MoveDatabase(ctx *fiber.Ctx, req dtos.MoveDatabaseRequest) (*dtos.MoveDatabaseResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.move").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.MoveDatabase(databaseDto.MoveDatabaseInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + DocumentId: req.DocumentId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to move database") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to move database", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MoveDatabaseResponse{ + Id: result.Id, + DocumentId: result.DocumentId, + }, nil +} + +func (ctrl *Controller) BulkDeleteRows(ctx *fiber.Ctx, req dtos.BulkDeleteRowsRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.row.bulk_delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if len(req.RowIds) == 0 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Row IDs are required", Type: "BAD_REQUEST"} + } + + err = ctrl.DatabaseApp.BulkDeleteRows(databaseDto.BulkDeleteRowsInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + RowIds: req.RowIds, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete rows") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete rows", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Rows deleted"}, nil +} + +func (ctrl *Controller) SearchDatabases(ctx *fiber.Ctx, req dtos.SearchDatabasesRequest) (*dtos.SearchDatabasesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.search").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if len(req.Query) < 2 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Query must be at least 2 characters", Type: "BAD_REQUEST"} + } + + result, err := ctrl.DatabaseApp.Search(databaseDto.SearchDatabasesInput{ + UserId: authCtx.UserID, + Query: req.Query, + SpaceId: req.SpaceId, + Limit: req.Limit, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to search databases") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to search databases", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.SearchDatabasesResponse{Results: make([]dtos.SearchDatabaseResultItem, len(result.Results))} + for i, r := range result.Results { + resp.Results[i] = dtos.SearchDatabaseResultItem{ + Id: r.Id, + Name: r.Name, + Description: r.Description, + Icon: r.Icon, + Type: r.Type, + SpaceId: r.SpaceId, + SpaceName: r.SpaceName, + UpdatedAt: r.UpdatedAt, + } + } + + return resp, nil +} diff --git a/interfaces/http/v1/database/handlers_permissions.go b/interfaces/http/v1/database/handlers_permissions.go new file mode 100644 index 0000000..1776deb --- /dev/null +++ b/interfaces/http/v1/database/handlers_permissions.go @@ -0,0 +1,123 @@ +package database + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + databaseDto "github.com/labbs/nexo/application/database/dto" + "github.com/labbs/nexo/interfaces/http/v1/database/dtos" +) + +func (ctrl *Controller) ListDatabasePermissions(ctx *fiber.Ctx, req dtos.ListDatabasePermissionsRequest) (*dtos.ListDatabasePermissionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.permissions.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DatabaseApp.ListDatabasePermissions(databaseDto.ListDatabasePermissionsInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to list permissions") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list permissions", Type: "INTERNAL_SERVER_ERROR"} + } + + permissions := make([]dtos.DatabasePermissionItem, len(result.Permissions)) + for i, p := range result.Permissions { + permissions[i] = dtos.DatabasePermissionItem{ + Id: p.Id, + UserId: p.UserId, + Username: p.Username, + GroupId: p.GroupId, + GroupName: p.GroupName, + Role: p.Role, + } + } + + return &dtos.ListDatabasePermissionsResponse{ + Permissions: permissions, + }, nil +} + +func (ctrl *Controller) UpsertDatabasePermission(ctx *fiber.Ctx, req dtos.UpsertDatabasePermissionRequest) (*dtos.UpsertDatabasePermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.permissions.upsert").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.UpsertDatabasePermission(databaseDto.UpsertDatabasePermissionInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + TargetUserId: req.UserId, + GroupId: req.GroupId, + Role: req.Role, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") || strings.Contains(err.Error(), "only creator") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: err.Error(), Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + if strings.Contains(err.Error(), "invalid role") || strings.Contains(err.Error(), "must provide") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: err.Error(), Type: "BAD_REQUEST"} + } + logger.Error().Err(err).Msg("failed to upsert permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to upsert permission", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.UpsertDatabasePermissionResponse{ + Success: true, + }, nil +} + +func (ctrl *Controller) DeleteDatabasePermission(ctx *fiber.Ctx, req dtos.DeleteDatabasePermissionRequest) (*dtos.DeleteDatabasePermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.database.permissions.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DatabaseApp.DeleteDatabasePermission(databaseDto.DeleteDatabasePermissionInput{ + UserId: authCtx.UserID, + DatabaseId: req.DatabaseId, + TargetUserId: req.UserId, + GroupId: req.GroupId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") || strings.Contains(err.Error(), "only creator") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: err.Error(), Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Database not found", Type: "NOT_FOUND"} + } + if strings.Contains(err.Error(), "must provide") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: err.Error(), Type: "BAD_REQUEST"} + } + logger.Error().Err(err).Msg("failed to delete permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete permission", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.DeleteDatabasePermissionResponse{ + Success: true, + }, nil +} diff --git a/interfaces/http/v1/database/router.go b/interfaces/http/v1/database/router.go new file mode 100644 index 0000000..2e3643f --- /dev/null +++ b/interfaces/http/v1/database/router.go @@ -0,0 +1,149 @@ +package database + +import fiberoapi "github.com/labbs/fiber-oapi" + +func SetupDatabaseRouter(ctrl Controller) { + // Database endpoints + fiberoapi.Get(ctrl.FiberOapi, "/", ctrl.ListDatabases, fiberoapi.OpenAPIOptions{ + Summary: "List databases", + Description: "List all databases in a space", + OperationID: "database.list", + Tags: []string{"Databases"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/", ctrl.CreateDatabase, fiberoapi.OpenAPIOptions{ + Summary: "Create database", + Description: "Create a new database", + OperationID: "database.create", + Tags: []string{"Databases"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/types", ctrl.GetAvailableTypes, fiberoapi.OpenAPIOptions{ + Summary: "Get available property types", + Description: "List all available property types for database columns", + OperationID: "database.types", + Tags: []string{"Databases"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/search", ctrl.SearchDatabases, fiberoapi.OpenAPIOptions{ + Summary: "Search databases", + Description: "Search databases by name or description", + OperationID: "database.search", + Tags: []string{"Databases", "Search"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:database_id", ctrl.GetDatabase, fiberoapi.OpenAPIOptions{ + Summary: "Get database", + Description: "Get a specific database by ID", + OperationID: "database.get", + Tags: []string{"Databases"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:database_id", ctrl.UpdateDatabase, fiberoapi.OpenAPIOptions{ + Summary: "Update database", + Description: "Update an existing database", + OperationID: "database.update", + Tags: []string{"Databases"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:database_id", ctrl.DeleteDatabase, fiberoapi.OpenAPIOptions{ + Summary: "Delete database", + Description: "Delete a database and all its rows", + OperationID: "database.delete", + Tags: []string{"Databases"}, + }) + + fiberoapi.Patch(ctrl.FiberOapi, "/:database_id/move", ctrl.MoveDatabase, fiberoapi.OpenAPIOptions{ + Summary: "Move database", + Description: "Move a database to a document or to root level", + OperationID: "database.move", + Tags: []string{"Databases"}, + }) + + // View endpoints + fiberoapi.Post(ctrl.FiberOapi, "/:database_id/views", ctrl.CreateView, fiberoapi.OpenAPIOptions{ + Summary: "Create view", + Description: "Create a new view for a database", + OperationID: "database.view.create", + Tags: []string{"Database Views"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:database_id/views/:view_id", ctrl.UpdateView, fiberoapi.OpenAPIOptions{ + Summary: "Update view", + Description: "Update an existing view", + OperationID: "database.view.update", + Tags: []string{"Database Views"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:database_id/views/:view_id", ctrl.DeleteView, fiberoapi.OpenAPIOptions{ + Summary: "Delete view", + Description: "Delete a view", + OperationID: "database.view.delete", + Tags: []string{"Database Views"}, + }) + + // Row endpoints + fiberoapi.Get(ctrl.FiberOapi, "/:database_id/rows", ctrl.ListRows, fiberoapi.OpenAPIOptions{ + Summary: "List rows", + Description: "List all rows in a database", + OperationID: "database.row.list", + Tags: []string{"Database Rows"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/:database_id/rows", ctrl.CreateRow, fiberoapi.OpenAPIOptions{ + Summary: "Create row", + Description: "Create a new row in a database", + OperationID: "database.row.create", + Tags: []string{"Database Rows"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:database_id/rows", ctrl.BulkDeleteRows, fiberoapi.OpenAPIOptions{ + Summary: "Bulk delete rows", + Description: "Delete multiple rows at once", + OperationID: "database.row.bulk_delete", + Tags: []string{"Database Rows"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:database_id/rows/:row_id", ctrl.GetRow, fiberoapi.OpenAPIOptions{ + Summary: "Get row", + Description: "Get a specific row by ID", + OperationID: "database.row.get", + Tags: []string{"Database Rows"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:database_id/rows/:row_id", ctrl.UpdateRow, fiberoapi.OpenAPIOptions{ + Summary: "Update row", + Description: "Update an existing row", + OperationID: "database.row.update", + Tags: []string{"Database Rows"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:database_id/rows/:row_id", ctrl.DeleteRow, fiberoapi.OpenAPIOptions{ + Summary: "Delete row", + Description: "Delete a row", + OperationID: "database.row.delete", + Tags: []string{"Database Rows"}, + }) + + // Permission endpoints + fiberoapi.Get(ctrl.FiberOapi, "/:database_id/permissions", ctrl.ListDatabasePermissions, fiberoapi.OpenAPIOptions{ + Summary: "List database permissions", + Description: "List all permissions for a database", + OperationID: "database.permission.list", + Tags: []string{"Database Permissions"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/:database_id/permissions", ctrl.UpsertDatabasePermission, fiberoapi.OpenAPIOptions{ + Summary: "Add or update permission", + Description: "Add or update a permission for a database", + OperationID: "database.permission.upsert", + Tags: []string{"Database Permissions"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:database_id/permissions", ctrl.DeleteDatabasePermission, fiberoapi.OpenAPIOptions{ + Summary: "Delete permission", + Description: "Delete a permission from a database", + OperationID: "database.permission.delete", + Tags: []string{"Database Permissions"}, + }) +} diff --git a/interfaces/http/v1/document/dtos/comment.go b/interfaces/http/v1/document/dtos/comment.go new file mode 100644 index 0000000..9245cce --- /dev/null +++ b/interfaces/http/v1/document/dtos/comment.go @@ -0,0 +1,78 @@ +package dtos + +import "time" + +// Request DTOs + +type GetCommentsRequest struct { + SpaceId string `path:"space_id"` + DocumentId string `path:"document_id"` +} + +type CreateCommentRequest struct { + ParentId *string `json:"parent_id,omitempty"` + Content string `json:"content"` + BlockId *string `json:"block_id,omitempty"` +} + +type CreateCommentRequestWithParams struct { + SpaceId string `path:"space_id"` + DocumentId string `path:"document_id"` + ParentId *string `json:"parent_id,omitempty"` + Content string `json:"content"` + BlockId *string `json:"block_id,omitempty"` +} + +type UpdateCommentRequest struct { + Content string `json:"content"` +} + +type UpdateCommentRequestWithParams struct { + SpaceId string `path:"space_id"` + DocumentId string `path:"document_id"` + CommentId string `path:"comment_id"` + Content string `json:"content"` +} + +type DeleteCommentRequest struct { + SpaceId string `path:"space_id"` + DocumentId string `path:"document_id"` + CommentId string `path:"comment_id"` +} + +type ResolveCommentRequest struct { + Resolved bool `json:"resolved"` +} + +type ResolveCommentRequestWithParams struct { + SpaceId string `path:"space_id"` + DocumentId string `path:"document_id"` + CommentId string `path:"comment_id"` + Resolved bool `json:"resolved"` +} + +// Response DTOs + +type CommentResponse struct { + Id string `json:"id"` + UserId string `json:"user_id"` + UserName string `json:"user_name"` + ParentId *string `json:"parent_id,omitempty"` + Content string `json:"content"` + BlockId *string `json:"block_id,omitempty"` + Resolved bool `json:"resolved"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type GetCommentsResponse struct { + Comments []CommentResponse `json:"comments"` +} + +type CreateCommentResponse struct { + CommentId string `json:"comment_id"` +} + +type MessageResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/document/dtos/common.go b/interfaces/http/v1/document/dtos/common.go new file mode 100644 index 0000000..8486771 --- /dev/null +++ b/interfaces/http/v1/document/dtos/common.go @@ -0,0 +1,51 @@ +package dtos + +import ( + "time" + + spaceDtos "github.com/labbs/nexo/interfaces/http/v1/space/dtos" +) + +type Document struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + + ParentId *string `json:"parent_id,omitempty"` + Parent *Document `json:"parent,omitempty"` + + SpaceId string `json:"space_id"` + Space spaceDtos.Space `json:"space"` + + Content []Block `json:"content"` + + Config DocumentConfig `json:"config"` + Metadata map[string]any `json:"metadata"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DocumentConfig struct { + FullWidth bool `json:"full_width"` + Icon string `json:"icon"` + Lock bool `json:"lock"` + HeaderBackground string `json:"header_background"` +} + +// Block représente un bloc BlockNote +type Block struct { + ID string `json:"id"` + Type string `json:"type"` + Props map[string]any `json:"props"` + Content []InlineContent `json:"content"` + Children []Block `json:"children"` +} + +// InlineContent représente le contenu inline (texte, liens, etc.) +type InlineContent struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + Href string `json:"href,omitempty"` + Styles map[string]bool `json:"styles"` +} diff --git a/interfaces/http/v1/document/dtos/create_document_request.go b/interfaces/http/v1/document/dtos/create_document_request.go new file mode 100644 index 0000000..ea67f6b --- /dev/null +++ b/interfaces/http/v1/document/dtos/create_document_request.go @@ -0,0 +1,10 @@ +package dtos + +type CreateDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + ParentId *string `json:"parent_id,omitempty" validate:"omitempty"` +} + +type CreateDocumentResponse struct { + Document +} diff --git a/interfaces/http/v1/document/dtos/delete_document_request.go b/interfaces/http/v1/document/dtos/delete_document_request.go new file mode 100644 index 0000000..32492f8 --- /dev/null +++ b/interfaces/http/v1/document/dtos/delete_document_request.go @@ -0,0 +1,10 @@ +package dtos + +type DeleteDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Identifier string `path:"identifier" validate:"required"` +} + +type DeleteDocumentResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/document/dtos/get_document_request.go b/interfaces/http/v1/document/dtos/get_document_request.go new file mode 100644 index 0000000..ef8be16 --- /dev/null +++ b/interfaces/http/v1/document/dtos/get_document_request.go @@ -0,0 +1,34 @@ +package dtos + +import ( + "time" + + spaceDtos "github.com/labbs/nexo/interfaces/http/v1/space/dtos" +) + +type GetDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Identifier string `path:"identifier" validate:"required"` +} + +type GetDocumentResponse struct { + Id string `json:"document"` + Name string `json:"name"` + Slug string `json:"slug"` + + ParentId *string `json:"parent_id,omitempty"` + Parent *Document `json:"parent,omitempty"` + + SpaceId string `json:"space_id"` + Space spaceDtos.Space `json:"space"` + + Content []Block `json:"content"` + + Config DocumentConfig `json:"config"` + Metadata map[string]any `json:"metadata"` + + Public bool `json:"public"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/interfaces/http/v1/document/dtos/get_documents_from_space_request.go b/interfaces/http/v1/document/dtos/get_documents_from_space_request.go new file mode 100644 index 0000000..4e74865 --- /dev/null +++ b/interfaces/http/v1/document/dtos/get_documents_from_space_request.go @@ -0,0 +1,10 @@ +package dtos + +type GetDocumentsFromSpaceRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + ParentId string `query:"parent_id" validate:"omitempty,uuid4"` +} + +type GetDocumentsFromSpaceResponse struct { + Documents []Document `json:"documents"` +} diff --git a/interfaces/http/v1/document/dtos/move_document_request.go b/interfaces/http/v1/document/dtos/move_document_request.go new file mode 100644 index 0000000..fbca905 --- /dev/null +++ b/interfaces/http/v1/document/dtos/move_document_request.go @@ -0,0 +1,17 @@ +package dtos + +type MoveDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Id string `path:"id" validate:"required"` + ParentId *string `json:"parent_id,omitempty" validate:"omitempty,uuid4"` +} + +type MoveDocumentResponse struct { + Id string `json:"id"` + ParentId *string `json:"parent_id,omitempty"` + SpaceId string `json:"space_id"` +} + + + + diff --git a/interfaces/http/v1/document/dtos/permissions.go b/interfaces/http/v1/document/dtos/permissions.go new file mode 100644 index 0000000..80457e8 --- /dev/null +++ b/interfaces/http/v1/document/dtos/permissions.go @@ -0,0 +1,38 @@ +package dtos + +type ListDocumentPermissionsRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required,uuid4"` +} + +type DocumentPermission struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + Role string `json:"role"` +} + +type ListDocumentPermissionsResponse struct { + Permissions []DocumentPermission `json:"permissions"` +} + +type UpsertDocumentUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required,uuid4"` + UserId string `json:"user_id" validate:"required,uuid4"` + Role string `json:"role" validate:"required,oneof=owner editor viewer denied"` +} + +type UpsertDocumentUserPermissionResponse struct { + Message string `json:"message"` +} + +type DeleteDocumentUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required,uuid4"` + UserId string `path:"user_id" validate:"required,uuid4"` +} + +type DeleteDocumentUserPermissionResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/document/dtos/public.go b/interfaces/http/v1/document/dtos/public.go new file mode 100644 index 0000000..791879e --- /dev/null +++ b/interfaces/http/v1/document/dtos/public.go @@ -0,0 +1,17 @@ +package dtos + +type SetPublicRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required,uuid4"` + Public bool `json:"public"` +} + +type SetPublicResponse struct { + Message string `json:"message"` + Public bool `json:"public"` +} + +type GetPublicDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Identifier string `path:"identifier" validate:"required"` +} diff --git a/interfaces/http/v1/document/dtos/reorder_document_request.go b/interfaces/http/v1/document/dtos/reorder_document_request.go new file mode 100644 index 0000000..a41ce1e --- /dev/null +++ b/interfaces/http/v1/document/dtos/reorder_document_request.go @@ -0,0 +1,15 @@ +package dtos + +type ReorderItem struct { + Id string `json:"id" validate:"required"` + Position int `json:"position" validate:"min=0"` +} + +type ReorderDocumentsRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Items []ReorderItem `json:"items" validate:"required,min=1"` +} + +type ReorderDocumentsResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/document/dtos/search.go b/interfaces/http/v1/document/dtos/search.go new file mode 100644 index 0000000..48f8d65 --- /dev/null +++ b/interfaces/http/v1/document/dtos/search.go @@ -0,0 +1,23 @@ +package dtos + +import "time" + +type SearchRequest struct { + Query string `query:"q"` + SpaceId *string `query:"space_id"` + Limit int `query:"limit"` +} + +type SearchResultItem struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + SpaceId string `json:"space_id"` + SpaceName string `json:"space_name"` + Icon string `json:"icon,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +type SearchResponse struct { + Results []SearchResultItem `json:"results"` +} diff --git a/interfaces/http/v1/document/dtos/trash.go b/interfaces/http/v1/document/dtos/trash.go new file mode 100644 index 0000000..f3aa9b3 --- /dev/null +++ b/interfaces/http/v1/document/dtos/trash.go @@ -0,0 +1,27 @@ +package dtos + +import "time" + +type GetTrashRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` +} + +type TrashDocument struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + DeletedAt time.Time `json:"deleted_at"` +} + +type GetTrashResponse struct { + Documents []TrashDocument `json:"documents"` +} + +type RestoreDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required,uuid4"` +} + +type RestoreDocumentResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/document/dtos/update_document_request.go b/interfaces/http/v1/document/dtos/update_document_request.go new file mode 100644 index 0000000..dd82d85 --- /dev/null +++ b/interfaces/http/v1/document/dtos/update_document_request.go @@ -0,0 +1,23 @@ +package dtos + +type UpdateDocumentRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Id string `path:"id" validate:"required"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1"` + Content *[]Block `json:"content,omitempty"` + ParentId *string `json:"parent_id,omitempty" validate:"omitempty"` + Config *DocumentConfig `json:"config,omitempty"` + Metadata *map[string]any `json:"metadata,omitempty"` + Public *bool `json:"public,omitempty"` +} + +type UpdateDocumentResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + ParentId *string `json:"parent_id,omitempty"` + SpaceId string `json:"space_id"` + Content []Block `json:"content"` + Config DocumentConfig `json:"config"` + Metadata map[string]any `json:"metadata"` +} diff --git a/interfaces/http/v1/document/dtos/version.go b/interfaces/http/v1/document/dtos/version.go new file mode 100644 index 0000000..3689307 --- /dev/null +++ b/interfaces/http/v1/document/dtos/version.go @@ -0,0 +1,72 @@ +package dtos + +import "time" + +// Request DTOs + +type ListVersionsRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required"` + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type GetVersionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required"` + VersionId string `path:"version_id" validate:"required,uuid4"` +} + +type RestoreVersionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required"` + VersionId string `path:"version_id" validate:"required,uuid4"` +} + +type CreateVersionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + DocumentId string `path:"document_id" validate:"required"` + Description string `json:"description"` +} + +// Response DTOs + +type VersionItem struct { + Id string `json:"id"` + Version int `json:"version"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + UserId string `json:"user_id"` + UserName string `json:"user_name"` + CreatedAt time.Time `json:"created_at"` +} + +type ListVersionsResponse struct { + Versions []VersionItem `json:"versions"` + TotalCount int64 `json:"total_count"` +} + +type VersionConfig struct { + FullWidth bool `json:"full_width"` + Icon string `json:"icon,omitempty"` + Lock bool `json:"lock"` + HeaderBackground string `json:"header_background,omitempty"` +} + +type GetVersionResponse struct { + Id string `json:"id"` + Version int `json:"version"` + DocumentId string `json:"document_id"` + Name string `json:"name"` + Content []Block `json:"content"` + Config VersionConfig `json:"config"` + Description string `json:"description,omitempty"` + UserId string `json:"user_id"` + UserName string `json:"user_name"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateVersionResponse struct { + VersionId string `json:"version_id"` + Version int `json:"version"` +} diff --git a/interfaces/http/v1/document/handlers.go b/interfaces/http/v1/document/handlers.go new file mode 100644 index 0000000..17ad290 --- /dev/null +++ b/interfaces/http/v1/document/handlers.go @@ -0,0 +1,1134 @@ +package document + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + docDto "github.com/labbs/nexo/application/document/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/mapper" + "github.com/labbs/nexo/infrastructure/helpers/validator" + "github.com/labbs/nexo/interfaces/http/v1/document/dtos" +) + +func (ctrl *Controller) GetDocumentsFromSpace(ctx *fiber.Ctx, req dtos.GetDocumentsFromSpaceRequest) (*dtos.GetDocumentsFromSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_documents_from_space").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + // Convert empty string to nil pointer for optional parentId + var parentId *string + if req.ParentId != "" { + parentId = &req.ParentId + } + + result, err := ctrl.DocumentApp.GetDocumentsFromSpaceWithUserPermissions(docDto.GetDocumentsFromSpaceInput{ + SpaceId: req.SpaceId, + UserId: authCtx.UserID, + ParentId: parentId, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to get documents from space") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve documents", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Map domain documents to DTO documents + resp := &dtos.GetDocumentsFromSpaceResponse{ + Documents: make([]dtos.Document, len(result.Documents)), + } + + for i, doc := range result.Documents { + // Map the document + err = mapper.MapStructByFieldNames(&doc, &resp.Documents[i]) + if err != nil { + logger.Error().Err(err).Msg("failed to map document to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Map the parent if it exists + if doc.Parent != nil { + resp.Documents[i].Parent = &dtos.Document{} + err = mapper.MapStructByFieldNames(doc.Parent, resp.Documents[i].Parent) + if err != nil { + logger.Error().Err(err).Msg("failed to map parent document to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process parent document", + Type: "INTERNAL_SERVER_ERROR", + } + } + } + + // Map the space + err = mapper.MapStructByFieldNames(&doc.Space, &resp.Documents[i].Space) + if err != nil { + logger.Error().Err(err).Msg("failed to map space to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process space", + Type: "INTERNAL_SERVER_ERROR", + } + } + } + + return resp, nil +} + +func (ctrl *Controller) GetDocument(ctx *fiber.Ctx, req dtos.GetDocumentRequest) (*dtos.GetDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_document").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + var id *string + var slug *string + + if validator.IsValidUUID(req.Identifier) { + id = &req.Identifier + } else { + slug = &req.Identifier + } + + result, err := ctrl.DocumentApp.GetDocumentWithSpace(docDto.GetDocumentWithSpaceInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: id, + Slug: slug, + }) + + logger.Debug().Interface("result", result).Msg("GetDocumentWithSpace result") + + if err != nil || result.Document == nil { + logger.Error().Err(err).Msg("failed to get document or document is nil") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusNotFound, + Details: "Document not found", + Type: "DOCUMENT_NOT_FOUND", + } + } + + resp := &dtos.GetDocumentResponse{} + err = mapper.MapStructByFieldNames(result.Document, resp) + if err != nil { + logger.Error().Err(err).Msg("failed to map document to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Manual conversion of Content from datatypes.JSON to []Block + if len(result.Document.Content) > 0 { + appBlocks := docDto.JSONToBlocks(result.Document.Content) + resp.Content = make([]dtos.Block, len(appBlocks)) + for i, b := range appBlocks { + resp.Content[i] = dtos.Block{ + ID: b.ID, + Type: b.Type, + Props: b.Props, + Content: convertToHttpInlineContent(b.Content), + Children: convertToHttpBlocks(b.Children), + } + } + } + + logger.Debug().Interface("resp.Content", resp.Content).Msg("Mapped document content") + + if result.Document.Parent != nil { + resp.Parent = &dtos.Document{} + err = mapper.MapStructByFieldNames(result.Document.Parent, resp.Parent) + if err != nil { + logger.Error().Err(err).Msg("failed to map parent document to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process parent document", + Type: "INTERNAL_SERVER_ERROR", + } + } + // Pour éviter la récursion infinie, on ne mappe pas le parent du parent + resp.Parent.Parent = nil + } + + // Map the space if it exists (avoid nil pointer dereference) + if result.Document.Space.Id != "" { + err = mapper.MapStructByFieldNames(&result.Document.Space, &resp.Space) + if err != nil { + logger.Error().Err(err).Msg("failed to map space to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process space", + Type: "INTERNAL_SERVER_ERROR", + } + } + } + + logger.Debug().Interface("resp", resp).Msg("Mapped document") + + return resp, nil +} + +func (ctrl *Controller) CreateDocument(ctx *fiber.Ctx, req dtos.CreateDocumentRequest) (*dtos.CreateDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.create_document").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + result, err := ctrl.DocumentApp.CreateDocument(docDto.CreateDocumentInput{ + Name: "New Document", + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + Content: []docDto.Block{}, + ParentId: req.ParentId, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to create document") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to create document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + if result.Document == nil { + logger.Error().Msg("document creation returned nil document") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to create document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + resp := &dtos.CreateDocumentResponse{} + resp.Id = result.Document.Id + resp.Name = result.Document.Name + resp.Slug = result.Document.Slug + resp.ParentId = result.Document.ParentId + resp.SpaceId = result.Document.SpaceId + resp.Content = []dtos.Block{} + resp.Config = dtos.DocumentConfig{} + resp.Metadata = map[string]any{} + resp.CreatedAt = result.Document.CreatedAt + resp.UpdatedAt = result.Document.UpdatedAt + + // Map the space if it exists + if result.Document.Space.Id != "" { + resp.Space.Id = result.Document.Space.Id + resp.Space.Name = result.Document.Space.Name + resp.Space.Slug = result.Document.Space.Slug + resp.Space.Icon = result.Document.Space.Icon + resp.Space.IconColor = result.Document.Space.IconColor + resp.Space.Type = string(result.Document.Space.Type) + resp.Space.CreatedAt = result.Document.Space.CreatedAt + resp.Space.UpdatedAt = result.Document.Space.UpdatedAt + } + + return resp, nil +} + +func (ctrl *Controller) UpdateDocument(ctx *fiber.Ctx, req dtos.UpdateDocumentRequest) (*dtos.UpdateDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.update_document").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + // Convert DTO config to domain config if provided + var domainConfig *domain.DocumentConfig + if req.Config != nil { + dc := domain.DocumentConfig{ + FullWidth: req.Config.FullWidth, + Icon: req.Config.Icon, + Lock: req.Config.Lock, + HeaderBackground: req.Config.HeaderBackground, + } + domainConfig = &dc + } + + // Convert metadata map to JSONB if provided + var domainMetadata *domain.JSONB + if req.Metadata != nil { + jsonb := domain.JSONB(*req.Metadata) + domainMetadata = &jsonb + } + + // Convert HTTP blocks to application blocks if provided + var appContent *[]docDto.Block + if req.Content != nil { + blocks := make([]docDto.Block, len(*req.Content)) + for i, b := range *req.Content { + blocks[i] = docDto.Block{ + ID: b.ID, + Type: b.Type, + Props: b.Props, + Content: convertInlineContent(b.Content), + Children: convertBlocks(b.Children), + } + } + appContent = &blocks + } + + result, err := ctrl.DocumentApp.UpdateDocument(docDto.UpdateDocumentInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.Id, + Name: req.Name, + Content: appContent, + ParentId: req.ParentId, + Config: domainConfig, + Metadata: domainMetadata, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to update document") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to update document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + resp := &dtos.UpdateDocumentResponse{} + err = mapper.MapStructByFieldNames(result.Document, resp) + if err != nil { + logger.Error().Err(err).Msg("failed to map updated document to response DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process updated document", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return resp, nil +} + +func (ctrl *Controller) DeleteDocument(ctx *fiber.Ctx, req dtos.DeleteDocumentRequest) (*dtos.DeleteDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.delete_document").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + var id *string + var slug *string + if validator.IsValidUUID(req.Identifier) { + id = &req.Identifier + } else { + slug = &req.Identifier + } + + if err := ctrl.DocumentApp.DeleteDocument(docDto.DeleteDocumentInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: id, + Slug: slug, + }); err != nil { + // Best-effort error typing + details := err.Error() + switch { + case strings.Contains(details, "insufficient permissions") || strings.Contains(details, "permission"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case strings.Contains(details, "child") || strings.Contains(details, "children"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusConflict, Details: "Document has child pages", Type: "CONFLICT"} + case strings.Contains(details, "not found"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document not found", Type: "DOCUMENT_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to delete document") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete document", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.DeleteDocumentResponse{Message: "Document deleted"}, nil +} + +func (ctrl *Controller) MoveDocument(ctx *fiber.Ctx, req dtos.MoveDocumentRequest) (*dtos.MoveDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.move_document").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.MoveDocument(docDto.MoveDocumentInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.Id, + NewParentId: req.ParentId, + }) + if err != nil { + details := err.Error() + switch { + case strings.Contains(details, "insufficient permissions"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case strings.Contains(details, "invalid move"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: details, Type: "BAD_REQUEST"} + case strings.Contains(details, "not found"): + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document or parent not found", Type: "NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to move document") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to move document", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.MoveDocumentResponse{Id: result.Document.Id, ParentId: result.Document.ParentId, SpaceId: result.Document.SpaceId}, nil +} + +// convertInlineContent converts HTTP DTO inline content to application DTO +func convertInlineContent(content []dtos.InlineContent) []docDto.InlineContent { + result := make([]docDto.InlineContent, len(content)) + for i, c := range content { + result[i] = docDto.InlineContent{ + Type: c.Type, + Text: c.Text, + Href: c.Href, + Styles: c.Styles, + } + } + return result +} + +// convertBlocks recursively converts HTTP DTO blocks to application DTO blocks +func convertBlocks(blocks []dtos.Block) []docDto.Block { + result := make([]docDto.Block, len(blocks)) + for i, b := range blocks { + result[i] = docDto.Block{ + ID: b.ID, + Type: b.Type, + Props: b.Props, + Content: convertInlineContent(b.Content), + Children: convertBlocks(b.Children), + } + } + return result +} + +// Permission handlers + +func (ctrl *Controller) ListDocumentPermissions(ctx *fiber.Ctx, req dtos.ListDocumentPermissionsRequest) (*dtos.ListDocumentPermissionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.list_permissions").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.ListDocumentPermissions(docDto.ListDocumentPermissionsInput{ + RequesterId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + }) + if err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document not found", Type: "DOCUMENT_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to list document permissions") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list permissions", Type: "INTERNAL_SERVER_ERROR"} + } + } + + resp := &dtos.ListDocumentPermissionsResponse{Permissions: make([]dtos.DocumentPermission, len(result.Permissions))} + for i, p := range result.Permissions { + perm := dtos.DocumentPermission{ + Id: p.Id, + UserId: p.UserId, + Role: string(p.Role), + } + if p.User != nil { + perm.Username = &p.User.Username + } + resp.Permissions[i] = perm + } + return resp, nil +} + +func (ctrl *Controller) UpsertDocumentUserPermission(ctx *fiber.Ctx, req dtos.UpsertDocumentUserPermissionRequest) (*dtos.UpsertDocumentUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.upsert_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + var role domain.PermissionRole + switch req.Role { + case string(domain.PermissionRoleOwner): + role = domain.PermissionRoleOwner + case string(domain.PermissionRoleEditor): + role = domain.PermissionRoleEditor + case string(domain.PermissionRoleDenied): + role = domain.PermissionRoleDenied + default: + role = domain.PermissionRoleViewer + } + + if err := ctrl.DocumentApp.UpsertDocumentUserPermission(docDto.UpsertDocumentUserPermissionInput{ + RequesterId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + TargetUserId: req.UserId, + Role: role, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document not found", Type: "DOCUMENT_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to upsert document permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to upsert permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.UpsertDocumentUserPermissionResponse{Message: "permission updated"}, nil +} + +func (ctrl *Controller) DeleteDocumentUserPermission(ctx *fiber.Ctx, req dtos.DeleteDocumentUserPermissionRequest) (*dtos.DeleteDocumentUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.delete_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if err := ctrl.DocumentApp.DeleteDocumentUserPermission(docDto.DeleteDocumentUserPermissionInput{ + RequesterId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + TargetUserId: req.UserId, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document not found", Type: "DOCUMENT_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to delete document permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.DeleteDocumentUserPermissionResponse{Message: "permission deleted"}, nil +} + +// Trash handlers + +func (ctrl *Controller) GetTrash(ctx *fiber.Ctx, req dtos.GetTrashRequest) (*dtos.GetTrashResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_trash").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.GetTrash(docDto.GetTrashInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to get trash") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get trash", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.GetTrashResponse{Documents: make([]dtos.TrashDocument, len(result.Documents))} + for i, doc := range result.Documents { + resp.Documents[i] = dtos.TrashDocument{ + Id: doc.Id, + Name: doc.Name, + Slug: doc.Slug, + DeletedAt: doc.DeletedAt.Time, + } + } + + return resp, nil +} + +func (ctrl *Controller) RestoreDocument(ctx *fiber.Ctx, req dtos.RestoreDocumentRequest) (*dtos.RestoreDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.restore_document").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.RestoreDocument(docDto.RestoreDocumentInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + }) + if err != nil { + switch { + case err.Error() == "access denied: insufficient permissions to restore document": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case err.Error() == "document is not deleted": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Document is not in trash", Type: "BAD_REQUEST"} + default: + logger.Error().Err(err).Msg("failed to restore document") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to restore document", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.RestoreDocumentResponse{Message: "Document restored successfully"}, nil +} + +// Public sharing handlers + +func (ctrl *Controller) SetPublic(ctx *fiber.Ctx, req dtos.SetPublicRequest) (*dtos.SetPublicResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.set_public").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.SetPublic(docDto.SetPublicInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + Public: req.Public, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to set document public status") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update document", Type: "INTERNAL_SERVER_ERROR"} + } + + message := "Document is now private" + if req.Public { + message = "Document is now public" + } + + return &dtos.SetPublicResponse{Message: message, Public: req.Public}, nil +} + +func (ctrl *Controller) GetPublicDocument(ctx *fiber.Ctx, req dtos.GetPublicDocumentRequest) (*dtos.GetDocumentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_public_document").Logger() + + var id *string + var slug *string + + if validator.IsValidUUID(req.Identifier) { + id = &req.Identifier + } else { + slug = &req.Identifier + } + + result, err := ctrl.DocumentApp.GetPublicDocument(docDto.GetPublicDocumentInput{ + SpaceId: req.SpaceId, + DocumentId: id, + Slug: slug, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to get public document") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Document not found or not public", Type: "DOCUMENT_NOT_FOUND"} + } + + resp := &dtos.GetDocumentResponse{} + err = mapper.MapStructByFieldNames(result.Document, resp) + if err != nil { + logger.Error().Err(err).Msg("failed to map document to response DTO") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to process document", Type: "INTERNAL_SERVER_ERROR"} + } + + return resp, nil +} + +// Comment handlers + +func (ctrl *Controller) GetComments(ctx *fiber.Ctx, req dtos.GetCommentsRequest) (*dtos.GetCommentsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_comments").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.GetComments(docDto.GetCommentsInput{ + UserId: authCtx.UserID, + DocumentId: req.DocumentId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to get comments") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get comments", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.GetCommentsResponse{Comments: make([]dtos.CommentResponse, len(result.Comments))} + for i, c := range result.Comments { + resp.Comments[i] = dtos.CommentResponse{ + Id: c.Id, + UserId: c.UserId, + UserName: c.UserName, + ParentId: c.ParentId, + Content: c.Content, + BlockId: c.BlockId, + Resolved: c.Resolved, + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) CreateComment(ctx *fiber.Ctx, req dtos.CreateCommentRequestWithParams) (*dtos.CreateCommentResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.create_comment").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.CreateComment(docDto.CreateCommentInput{ + UserId: authCtx.UserID, + DocumentId: req.DocumentId, + ParentId: req.ParentId, + Content: req.Content, + BlockId: req.BlockId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to create comment") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create comment", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateCommentResponse{CommentId: result.CommentId}, nil +} + +func (ctrl *Controller) UpdateComment(ctx *fiber.Ctx, req dtos.UpdateCommentRequestWithParams) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.update_comment").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.UpdateComment(docDto.UpdateCommentInput{ + UserId: authCtx.UserID, + CommentId: req.CommentId, + Content: req.Content, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Only the comment author can update it", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Comment not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update comment") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update comment", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Comment updated"}, nil +} + +func (ctrl *Controller) DeleteComment(ctx *fiber.Ctx, req dtos.DeleteCommentRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.delete_comment").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.DeleteComment(docDto.DeleteCommentInput{ + UserId: authCtx.UserID, + CommentId: req.CommentId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Only the comment author can delete it", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Comment not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete comment") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete comment", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Comment deleted"}, nil +} + +func (ctrl *Controller) ResolveComment(ctx *fiber.Ctx, req dtos.ResolveCommentRequestWithParams) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.resolve_comment").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.ResolveComment(docDto.ResolveCommentInput{ + UserId: authCtx.UserID, + CommentId: req.CommentId, + Resolved: req.Resolved, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Comment not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to resolve comment") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to resolve comment", Type: "INTERNAL_SERVER_ERROR"} + } + + message := "Comment unresolved" + if req.Resolved { + message = "Comment resolved" + } + + return &dtos.MessageResponse{Message: message}, nil +} + +// Search handler + +func (ctrl *Controller) SearchDocuments(ctx *fiber.Ctx, req dtos.SearchRequest) (*dtos.SearchResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.search").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if len(req.Query) < 2 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Query must be at least 2 characters", Type: "BAD_REQUEST"} + } + + result, err := ctrl.DocumentApp.Search(docDto.SearchInput{ + UserId: authCtx.UserID, + Query: req.Query, + SpaceId: req.SpaceId, + Limit: req.Limit, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to search documents") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to search documents", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.SearchResponse{Results: make([]dtos.SearchResultItem, len(result.Results))} + for i, r := range result.Results { + resp.Results[i] = dtos.SearchResultItem{ + Id: r.Id, + Name: r.Name, + Slug: r.Slug, + SpaceId: r.SpaceId, + SpaceName: r.SpaceName, + Icon: r.Icon, + UpdatedAt: r.UpdatedAt, + } + } + + return resp, nil +} + +// Version handlers + +func (ctrl *Controller) ListVersions(ctx *fiber.Ctx, req dtos.ListVersionsRequest) (*dtos.ListVersionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.list_versions").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + logger.Debug().Str("spaceId", req.SpaceId).Str("documentId", req.DocumentId).Str("userId", authCtx.UserID).Msg("ListVersions called") + + result, err := ctrl.DocumentApp.ListVersions(docDto.ListVersionsInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + Limit: req.Limit, + Offset: req.Offset, + }) + if err != nil { + logger.Error().Err(err).Str("spaceId", req.SpaceId).Str("documentId", req.DocumentId).Msg("failed to list versions") + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list versions", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListVersionsResponse{ + Versions: make([]dtos.VersionItem, len(result.Versions)), + TotalCount: result.TotalCount, + } + for i, v := range result.Versions { + resp.Versions[i] = dtos.VersionItem{ + Id: v.Id, + Version: v.Version, + Name: v.Name, + Description: v.Description, + UserId: v.UserId, + UserName: v.UserName, + CreatedAt: v.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) GetVersion(ctx *fiber.Ctx, req dtos.GetVersionRequest) (*dtos.GetVersionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.get_version").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.GetVersion(docDto.GetVersionInput{ + UserId: authCtx.UserID, + VersionId: req.VersionId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Version not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get version") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get version", Type: "INTERNAL_SERVER_ERROR"} + } + + // Convert content blocks + content := make([]dtos.Block, len(result.Content)) + for i, b := range result.Content { + content[i] = dtos.Block{ + ID: b.ID, + Type: b.Type, + Props: b.Props, + Content: convertToHttpInlineContent(b.Content), + Children: convertToHttpBlocks(b.Children), + } + } + + return &dtos.GetVersionResponse{ + Id: result.Id, + Version: result.Version, + DocumentId: result.DocumentId, + Name: result.Name, + Content: content, + Config: dtos.VersionConfig{ + FullWidth: result.Config.FullWidth, + Icon: result.Config.Icon, + Lock: result.Config.Lock, + HeaderBackground: result.Config.HeaderBackground, + }, + Description: result.Description, + UserId: result.UserId, + UserName: result.UserName, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) RestoreVersion(ctx *fiber.Ctx, req dtos.RestoreVersionRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.restore_version").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DocumentApp.RestoreVersion(docDto.RestoreVersionInput{ + UserId: authCtx.UserID, + VersionId: req.VersionId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Version not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to restore version") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to restore version", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Version restored successfully"}, nil +} + +func (ctrl *Controller) CreateVersion(ctx *fiber.Ctx, req dtos.CreateVersionRequest) (*dtos.CreateVersionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.create_version").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DocumentApp.CreateVersion(docDto.CreateVersionInput{ + UserId: authCtx.UserID, + DocumentId: req.DocumentId, + Description: req.Description, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to create version") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create version", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateVersionResponse{ + VersionId: result.VersionId, + Version: result.Version, + }, nil +} + +// Reorder handler + +func (ctrl *Controller) ReorderDocuments(ctx *fiber.Ctx, req dtos.ReorderDocumentsRequest) (*dtos.ReorderDocumentsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.document.reorder_documents").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + items := make([]docDto.ReorderItem, len(req.Items)) + for i, item := range req.Items { + items[i] = docDto.ReorderItem{ + Id: item.Id, + Position: item.Position, + } + } + + err = ctrl.DocumentApp.ReorderDocuments(docDto.ReorderDocumentsInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + Items: items, + }) + if err != nil { + if strings.Contains(err.Error(), "insufficient permissions") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to reorder documents") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to reorder documents", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.ReorderDocumentsResponse{Message: "Documents reordered successfully"}, nil +} + +// Helper functions for converting blocks +func convertToHttpInlineContent(content []docDto.InlineContent) []dtos.InlineContent { + result := make([]dtos.InlineContent, len(content)) + for i, c := range content { + result[i] = dtos.InlineContent{ + Type: c.Type, + Text: c.Text, + Href: c.Href, + Styles: c.Styles, + } + } + return result +} + +func convertToHttpBlocks(blocks []docDto.Block) []dtos.Block { + result := make([]dtos.Block, len(blocks)) + for i, b := range blocks { + result[i] = dtos.Block{ + ID: b.ID, + Type: b.Type, + Props: b.Props, + Content: convertToHttpInlineContent(b.Content), + Children: convertToHttpBlocks(b.Children), + } + } + return result +} diff --git a/interfaces/http/v1/document/router.go b/interfaces/http/v1/document/router.go new file mode 100644 index 0000000..eebcec9 --- /dev/null +++ b/interfaces/http/v1/document/router.go @@ -0,0 +1,180 @@ +package document + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/document" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + SpaceApp *space.SpaceApp + DocumentApp *document.DocumentApp +} + +func SetupDocumentRouter(controller Controller) { + // Search - must be before parameterized routes + fiberoapi.Get(controller.FiberOapi, "/search", controller.SearchDocuments, fiberoapi.OpenAPIOptions{ + Summary: "Search documents", + Description: "Search documents by name or content", + OperationID: "document.search", + Tags: []string{"Document", "Search"}, + }) + + // Public document - before space routes + fiberoapi.Get(controller.FiberOapi, "/public/:space_id/:identifier", controller.GetPublicDocument, fiberoapi.OpenAPIOptions{ + Summary: "Get public document", + Description: "Get a public document without authentication", + OperationID: "document.getPublicDocument", + Tags: []string{"Document", "Public"}, + }) + + // Space-level routes (no document ID) + fiberoapi.Get(controller.FiberOapi, "/space/:space_id", controller.GetDocumentsFromSpace, fiberoapi.OpenAPIOptions{ + Summary: "Get documents from space", + Description: "Retrieve documents from a specific space", + OperationID: "document.getDocumentsFromSpace", + Tags: []string{"Document"}, + }) + fiberoapi.Post(controller.FiberOapi, "/space/:space_id", controller.CreateDocument, fiberoapi.OpenAPIOptions{ + Summary: "Create a new document", + Description: "Create a new document in a specified space", + OperationID: "document.createDocument", + Tags: []string{"Document"}, + }) + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/trash", controller.GetTrash, fiberoapi.OpenAPIOptions{ + Summary: "Get trash", + Description: "Get all deleted documents in a space", + OperationID: "document.getTrash", + Tags: []string{"Document", "Trash"}, + }) + fiberoapi.Patch(controller.FiberOapi, "/space/:space_id/reorder", controller.ReorderDocuments, fiberoapi.OpenAPIOptions{ + Summary: "Reorder documents", + Description: "Reorder documents within a space by updating their positions", + OperationID: "document.reorderDocuments", + Tags: []string{"Document"}, + }) + + // Routes with specific suffixes - MUST be before generic /:identifier routes + // Version history + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/:document_id/versions", controller.ListVersions, fiberoapi.OpenAPIOptions{ + Summary: "List document versions", + Description: "Get version history for a document", + OperationID: "document.listVersions", + Tags: []string{"Document", "Versions"}, + }) + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/:document_id/versions/:version_id", controller.GetVersion, fiberoapi.OpenAPIOptions{ + Summary: "Get document version", + Description: "Get a specific version of a document", + OperationID: "document.getVersion", + Tags: []string{"Document", "Versions"}, + }) + fiberoapi.Post(controller.FiberOapi, "/space/:space_id/:document_id/versions", controller.CreateVersion, fiberoapi.OpenAPIOptions{ + Summary: "Create version snapshot", + Description: "Manually create a version snapshot of the current document state", + OperationID: "document.createVersion", + Tags: []string{"Document", "Versions"}, + }) + fiberoapi.Post(controller.FiberOapi, "/space/:space_id/:document_id/versions/:version_id/restore", controller.RestoreVersion, fiberoapi.OpenAPIOptions{ + Summary: "Restore document version", + Description: "Restore a document to a previous version", + OperationID: "document.restoreVersion", + Tags: []string{"Document", "Versions"}, + }) + + // Comments + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/:document_id/comments", controller.GetComments, fiberoapi.OpenAPIOptions{ + Summary: "Get document comments", + Description: "Get all comments for a document", + OperationID: "document.getComments", + Tags: []string{"Document", "Comments"}, + }) + fiberoapi.Post(controller.FiberOapi, "/space/:space_id/:document_id/comments", controller.CreateComment, fiberoapi.OpenAPIOptions{ + Summary: "Create comment", + Description: "Create a new comment on a document", + OperationID: "document.createComment", + Tags: []string{"Document", "Comments"}, + }) + fiberoapi.Put(controller.FiberOapi, "/space/:space_id/:document_id/comments/:comment_id", controller.UpdateComment, fiberoapi.OpenAPIOptions{ + Summary: "Update comment", + Description: "Update an existing comment", + OperationID: "document.updateComment", + Tags: []string{"Document", "Comments"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/space/:space_id/:document_id/comments/:comment_id", controller.DeleteComment, fiberoapi.OpenAPIOptions{ + Summary: "Delete comment", + Description: "Delete a comment", + OperationID: "document.deleteComment", + Tags: []string{"Document", "Comments"}, + }) + fiberoapi.Patch(controller.FiberOapi, "/space/:space_id/:document_id/comments/:comment_id/resolve", controller.ResolveComment, fiberoapi.OpenAPIOptions{ + Summary: "Resolve/unresolve comment", + Description: "Mark a comment as resolved or unresolved", + OperationID: "document.resolveComment", + Tags: []string{"Document", "Comments"}, + }) + + // Document permissions + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/:document_id/permissions", controller.ListDocumentPermissions, fiberoapi.OpenAPIOptions{ + Summary: "List document permissions", + Description: "List all permissions for a specific document", + OperationID: "document.listPermissions", + Tags: []string{"Document", "Permissions"}, + }) + fiberoapi.Put(controller.FiberOapi, "/space/:space_id/:document_id/permissions", controller.UpsertDocumentUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Upsert document user permission", + Description: "Create or update a user permission for a document", + OperationID: "document.upsertUserPermission", + Tags: []string{"Document", "Permissions"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/space/:space_id/:document_id/permissions/:user_id", controller.DeleteDocumentUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Delete document user permission", + Description: "Remove a user permission from a document", + OperationID: "document.deleteUserPermission", + Tags: []string{"Document", "Permissions"}, + }) + + // Other document-specific routes with suffixes + fiberoapi.Post(controller.FiberOapi, "/space/:space_id/:document_id/restore", controller.RestoreDocument, fiberoapi.OpenAPIOptions{ + Summary: "Restore document", + Description: "Restore a deleted document from trash", + OperationID: "document.restoreDocument", + Tags: []string{"Document", "Trash"}, + }) + fiberoapi.Put(controller.FiberOapi, "/space/:space_id/:document_id/public", controller.SetPublic, fiberoapi.OpenAPIOptions{ + Summary: "Set document public status", + Description: "Make a document public or private", + OperationID: "document.setPublic", + Tags: []string{"Document", "Public"}, + }) + fiberoapi.Patch(controller.FiberOapi, "/space/:space_id/:id/move", controller.MoveDocument, fiberoapi.OpenAPIOptions{ + Summary: "Move document", + Description: "Move a document to a new parent (or root)", + OperationID: "document.moveDocument", + Tags: []string{"Document"}, + }) + + // Generic document routes - MUST be LAST + fiberoapi.Get(controller.FiberOapi, "/space/:space_id/:identifier", controller.GetDocument, fiberoapi.OpenAPIOptions{ + Summary: "Get document by ID", + Description: "Retrieve a specific document by its ID", + OperationID: "document.getDocument", + Tags: []string{"Document"}, + }) + fiberoapi.Put(controller.FiberOapi, "/space/:space_id/:id", controller.UpdateDocument, fiberoapi.OpenAPIOptions{ + Summary: "Update document", + Description: "Update a specific document", + OperationID: "document.updateDocument", + Tags: []string{"Document"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/space/:space_id/:identifier", controller.DeleteDocument, fiberoapi.OpenAPIOptions{ + Summary: "Delete document", + Description: "Delete a specific document", + OperationID: "document.deleteDocument", + Tags: []string{"Document"}, + }) +} diff --git a/interfaces/http/v1/drawing/controller.go b/interfaces/http/v1/drawing/controller.go new file mode 100644 index 0000000..7d946f7 --- /dev/null +++ b/interfaces/http/v1/drawing/controller.go @@ -0,0 +1,15 @@ +package drawing + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/drawing" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + DrawingApp *drawing.DrawingApp +} diff --git a/interfaces/http/v1/drawing/dtos/drawing.go b/interfaces/http/v1/drawing/dtos/drawing.go new file mode 100644 index 0000000..adb09ad --- /dev/null +++ b/interfaces/http/v1/drawing/dtos/drawing.go @@ -0,0 +1,91 @@ +package dtos + +import "time" + +// Request DTOs + +type CreateDrawingRequest struct { + SpaceId string `json:"space_id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Elements []interface{} `json:"elements,omitempty"` + AppState map[string]interface{} `json:"app_state,omitempty"` + Files map[string]interface{} `json:"files,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` +} + +type ListDrawingsRequest struct { + SpaceId string `query:"space_id"` +} + +type GetDrawingRequest struct { + DrawingId string `path:"drawing_id"` +} + +type UpdateDrawingRequest struct { + DrawingId string `path:"drawing_id"` + Name *string `json:"name,omitempty"` + Icon *string `json:"icon,omitempty"` + Elements []interface{} `json:"elements,omitempty"` + AppState map[string]interface{} `json:"app_state,omitempty"` + Files map[string]interface{} `json:"files,omitempty"` + Thumbnail *string `json:"thumbnail,omitempty"` +} + +type DeleteDrawingRequest struct { + DrawingId string `path:"drawing_id"` +} + +// Move drawing +type MoveDrawingRequest struct { + DrawingId string `path:"drawing_id"` + DocumentId *string `json:"document_id"` +} + +type MoveDrawingResponse struct { + Id string `json:"id"` + DocumentId *string `json:"document_id,omitempty"` +} + +// Response DTOs + +type MessageResponse struct { + Message string `json:"message"` +} + +type CreateDrawingResponse struct { + Id string `json:"id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` +} + +type DrawingItem struct { + Id string `json:"id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Thumbnail string `json:"thumbnail,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListDrawingsResponse struct { + Drawings []DrawingItem `json:"drawings"` +} + +type GetDrawingResponse struct { + Id string `json:"id"` + SpaceId string `json:"space_id"` + DocumentId *string `json:"document_id,omitempty"` + Name string `json:"name"` + Icon string `json:"icon,omitempty"` + Elements []interface{} `json:"elements"` + AppState map[string]interface{} `json:"app_state"` + Files map[string]interface{} `json:"files"` + Thumbnail string `json:"thumbnail,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/interfaces/http/v1/drawing/dtos/permissions.go b/interfaces/http/v1/drawing/dtos/permissions.go new file mode 100644 index 0000000..8c65653 --- /dev/null +++ b/interfaces/http/v1/drawing/dtos/permissions.go @@ -0,0 +1,35 @@ +package dtos + +type ListDrawingPermissionsRequest struct { + DrawingId string `path:"drawing_id" validate:"required,uuid4"` +} + +type DrawingPermission struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username *string `json:"username,omitempty"` + Role string `json:"role"` +} + +type ListDrawingPermissionsResponse struct { + Permissions []DrawingPermission `json:"permissions"` +} + +type UpsertDrawingUserPermissionRequest struct { + DrawingId string `path:"drawing_id" validate:"required,uuid4"` + UserId string `json:"user_id" validate:"required,uuid4"` + Role string `json:"role" validate:"required,oneof=owner editor viewer denied"` +} + +type UpsertDrawingUserPermissionResponse struct { + Message string `json:"message"` +} + +type DeleteDrawingUserPermissionRequest struct { + DrawingId string `path:"drawing_id" validate:"required,uuid4"` + UserId string `path:"user_id" validate:"required,uuid4"` +} + +type DeleteDrawingUserPermissionResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/drawing/handlers.go b/interfaces/http/v1/drawing/handlers.go new file mode 100644 index 0000000..29f6703 --- /dev/null +++ b/interfaces/http/v1/drawing/handlers.go @@ -0,0 +1,347 @@ +package drawing + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + drawingDto "github.com/labbs/nexo/application/drawing/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/interfaces/http/v1/drawing/dtos" +) + +func (ctrl *Controller) CreateDrawing(ctx *fiber.Ctx, req dtos.CreateDrawingRequest) (*dtos.CreateDrawingResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + + if req.SpaceId == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Space ID is required", Type: "BAD_REQUEST"} + } + + result, err := ctrl.DrawingApp.CreateDrawing(drawingDto.CreateDrawingInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + DocumentId: req.DocumentId, + Name: req.Name, + Icon: req.Icon, + Elements: req.Elements, + AppState: req.AppState, + Files: req.Files, + Thumbnail: req.Thumbnail, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to create drawing") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create drawing", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateDrawingResponse{ + Id: result.Id, + Name: result.Name, + CreatedAt: result.CreatedAt, + }, nil +} + +func (ctrl *Controller) ListDrawings(ctx *fiber.Ctx, req dtos.ListDrawingsRequest) (*dtos.ListDrawingsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.SpaceId == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Space ID is required", Type: "BAD_REQUEST"} + } + + result, err := ctrl.DrawingApp.ListDrawings(drawingDto.ListDrawingsInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + logger.Error().Err(err).Msg("failed to list drawings") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list drawings", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListDrawingsResponse{Drawings: make([]dtos.DrawingItem, len(result.Drawings))} + for i, d := range result.Drawings { + resp.Drawings[i] = dtos.DrawingItem{ + Id: d.Id, + DocumentId: d.DocumentId, + Name: d.Name, + Icon: d.Icon, + Thumbnail: d.Thumbnail, + CreatedBy: d.CreatedBy, + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) GetDrawing(ctx *fiber.Ctx, req dtos.GetDrawingRequest) (*dtos.GetDrawingResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.get").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DrawingApp.GetDrawing(drawingDto.GetDrawingInput{ + UserId: authCtx.UserID, + DrawingId: req.DrawingId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get drawing") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get drawing", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.GetDrawingResponse{ + Id: result.Id, + SpaceId: result.SpaceId, + DocumentId: result.DocumentId, + Name: result.Name, + Icon: result.Icon, + Elements: result.Elements, + AppState: result.AppState, + Files: result.Files, + Thumbnail: result.Thumbnail, + CreatedBy: result.CreatedBy, + CreatedAt: result.CreatedAt, + UpdatedAt: result.UpdatedAt, + }, nil +} + +func (ctrl *Controller) UpdateDrawing(ctx *fiber.Ctx, req dtos.UpdateDrawingRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DrawingApp.UpdateDrawing(drawingDto.UpdateDrawingInput{ + UserId: authCtx.UserID, + DrawingId: req.DrawingId, + Name: req.Name, + Icon: req.Icon, + Elements: req.Elements, + AppState: req.AppState, + Files: req.Files, + Thumbnail: req.Thumbnail, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update drawing") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update drawing", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Drawing updated successfully"}, nil +} + +func (ctrl *Controller) DeleteDrawing(ctx *fiber.Ctx, req dtos.DeleteDrawingRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.DrawingApp.DeleteDrawing(drawingDto.DeleteDrawingInput{ + UserId: authCtx.UserID, + DrawingId: req.DrawingId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete drawing") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete drawing", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Drawing deleted successfully"}, nil +} + +func (ctrl *Controller) MoveDrawing(ctx *fiber.Ctx, req dtos.MoveDrawingRequest) (*dtos.MoveDrawingResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.move").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DrawingApp.MoveDrawing(drawingDto.MoveDrawingInput{ + UserId: authCtx.UserID, + DrawingId: req.DrawingId, + DocumentId: req.DocumentId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to move drawing") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to move drawing", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MoveDrawingResponse{ + Id: result.Id, + DocumentId: result.DocumentId, + }, nil +} + +// Permission handlers + +func (ctrl *Controller) ListDrawingPermissions(ctx *fiber.Ctx, req dtos.ListDrawingPermissionsRequest) (*dtos.ListDrawingPermissionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.list_permissions").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.DrawingApp.ListDrawingPermissions(drawingDto.ListDrawingPermissionsInput{ + RequesterId: authCtx.UserID, + DrawingId: req.DrawingId, + }) + if err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to list drawing permissions") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list permissions", Type: "INTERNAL_SERVER_ERROR"} + } + } + + resp := &dtos.ListDrawingPermissionsResponse{Permissions: make([]dtos.DrawingPermission, len(result.Permissions))} + for i, p := range result.Permissions { + perm := dtos.DrawingPermission{ + Id: p.Id, + UserId: p.UserId, + Role: string(p.Role), + } + if p.User != nil { + perm.Username = &p.User.Username + } + resp.Permissions[i] = perm + } + return resp, nil +} + +func (ctrl *Controller) UpsertDrawingUserPermission(ctx *fiber.Ctx, req dtos.UpsertDrawingUserPermissionRequest) (*dtos.UpsertDrawingUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.upsert_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + var role domain.PermissionRole + switch req.Role { + case string(domain.PermissionRoleOwner): + role = domain.PermissionRoleOwner + case string(domain.PermissionRoleEditor): + role = domain.PermissionRoleEditor + case string(domain.PermissionRoleDenied): + role = domain.PermissionRoleDenied + default: + role = domain.PermissionRoleViewer + } + + if err := ctrl.DrawingApp.UpsertDrawingUserPermission(drawingDto.UpsertDrawingUserPermissionInput{ + RequesterId: authCtx.UserID, + DrawingId: req.DrawingId, + TargetUserId: req.UserId, + Role: role, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to upsert drawing permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to upsert permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.UpsertDrawingUserPermissionResponse{Message: "permission updated"}, nil +} + +func (ctrl *Controller) DeleteDrawingUserPermission(ctx *fiber.Ctx, req dtos.DeleteDrawingUserPermissionRequest) (*dtos.DeleteDrawingUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.drawing.delete_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if err := ctrl.DrawingApp.DeleteDrawingUserPermission(drawingDto.DeleteDrawingUserPermissionInput{ + RequesterId: authCtx.UserID, + DrawingId: req.DrawingId, + TargetUserId: req.UserId, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Drawing not found", Type: "NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to delete drawing permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.DeleteDrawingUserPermissionResponse{Message: "permission deleted"}, nil +} diff --git a/interfaces/http/v1/drawing/router.go b/interfaces/http/v1/drawing/router.go new file mode 100644 index 0000000..1356754 --- /dev/null +++ b/interfaces/http/v1/drawing/router.go @@ -0,0 +1,69 @@ +package drawing + +import fiberoapi "github.com/labbs/fiber-oapi" + +func SetupDrawingRouter(ctrl Controller) { + fiberoapi.Get(ctrl.FiberOapi, "/", ctrl.ListDrawings, fiberoapi.OpenAPIOptions{ + Summary: "List drawings", + Description: "List all drawings in a space", + OperationID: "drawing.list", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/", ctrl.CreateDrawing, fiberoapi.OpenAPIOptions{ + Summary: "Create drawing", + Description: "Create a new Excalidraw drawing", + OperationID: "drawing.create", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:drawing_id", ctrl.GetDrawing, fiberoapi.OpenAPIOptions{ + Summary: "Get drawing", + Description: "Get a specific drawing by ID", + OperationID: "drawing.get", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:drawing_id", ctrl.UpdateDrawing, fiberoapi.OpenAPIOptions{ + Summary: "Update drawing", + Description: "Update an existing drawing", + OperationID: "drawing.update", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:drawing_id", ctrl.DeleteDrawing, fiberoapi.OpenAPIOptions{ + Summary: "Delete drawing", + Description: "Delete a drawing", + OperationID: "drawing.delete", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Patch(ctrl.FiberOapi, "/:drawing_id/move", ctrl.MoveDrawing, fiberoapi.OpenAPIOptions{ + Summary: "Move drawing", + Description: "Move a drawing to a document or to root level", + OperationID: "drawing.move", + Tags: []string{"Drawings"}, + }) + + // Permission routes + fiberoapi.Get(ctrl.FiberOapi, "/:drawing_id/permissions", ctrl.ListDrawingPermissions, fiberoapi.OpenAPIOptions{ + Summary: "List drawing permissions", + Description: "List all permissions for a drawing", + OperationID: "drawing.permissions.list", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:drawing_id/permissions/user", ctrl.UpsertDrawingUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Upsert drawing user permission", + Description: "Create or update a user permission for a drawing", + OperationID: "drawing.permissions.upsert_user", + Tags: []string{"Drawings"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:drawing_id/permissions/user/:user_id", ctrl.DeleteDrawingUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Delete drawing user permission", + Description: "Delete a user permission from a drawing", + OperationID: "drawing.permissions.delete_user", + Tags: []string{"Drawings"}, + }) +} diff --git a/interfaces/http/v1/router.go b/interfaces/http/v1/router.go new file mode 100644 index 0000000..8c8475e --- /dev/null +++ b/interfaces/http/v1/router.go @@ -0,0 +1,108 @@ +package v1 + +import ( + "github.com/labbs/nexo/infrastructure" + "github.com/labbs/nexo/interfaces/http/app" + "github.com/labbs/nexo/interfaces/http/v1/action" + "github.com/labbs/nexo/interfaces/http/v1/admin" + "github.com/labbs/nexo/interfaces/http/v1/apikey" + "github.com/labbs/nexo/interfaces/http/v1/auth" + "github.com/labbs/nexo/interfaces/http/v1/database" + "github.com/labbs/nexo/interfaces/http/v1/document" + "github.com/labbs/nexo/interfaces/http/v1/drawing" + "github.com/labbs/nexo/interfaces/http/v1/space" + "github.com/labbs/nexo/interfaces/http/v1/user" + "github.com/labbs/nexo/interfaces/http/v1/webhook" +) + +func SetupRouterV1(deps infrastructure.Deps) { + deps.Logger.Info().Str("component", "http.router.v1").Msg("Setting up API v1 routes") + grp := deps.Http.FiberOapi.Group("/api/v1") + + authCtrl := auth.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/auth"), + AuthApp: deps.AuthApp, + } + auth.SetupAuthRouter(authCtrl) + + userCtrl := user.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/user"), + UserApp: deps.UserApp, + SpaceApp: deps.SpaceApp, + } + user.SetupUserRouter(userCtrl) + + spaceCtrl := space.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/space"), + SpaceApp: deps.SpaceApp, + } + space.SetupSpaceRouter(spaceCtrl) + + documentCtrl := document.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/document"), + SpaceApp: deps.SpaceApp, + DocumentApp: deps.DocumentApp, + } + document.SetupDocumentRouter(documentCtrl) + + apiKeyCtrl := apikey.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/apikeys"), + ApiKeyApp: deps.ApiKeyApp, + } + apikey.SetupApiKeyRouter(apiKeyCtrl) + + webhookCtrl := webhook.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/webhooks"), + WebhookApp: deps.WebhookApp, + } + webhook.SetupWebhookRouter(webhookCtrl) + + databaseCtrl := database.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/databases"), + DatabaseApp: deps.DatabaseApp, + } + database.SetupDatabaseRouter(databaseCtrl) + + drawingCtrl := drawing.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/drawings"), + DrawingApp: deps.DrawingApp, + } + drawing.SetupDrawingRouter(drawingCtrl) + + actionCtrl := action.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/actions"), + ActionApp: deps.ActionApp, + } + action.SetupActionRouter(actionCtrl) + + adminCtrl := admin.Controller{ + Config: deps.Config, + Logger: deps.Logger, + FiberOapi: grp.Group("/admin"), + UserApp: deps.UserApp, + SpaceApp: deps.SpaceApp, + ApiKeyApp: deps.ApiKeyApp, + GroupApp: deps.GroupApp, + } + admin.SetupAdminRouter(adminCtrl) + + app.SetupRouterApp(deps) +} diff --git a/interfaces/http/v1/space/dtos/common.go b/interfaces/http/v1/space/dtos/common.go new file mode 100644 index 0000000..df80fc0 --- /dev/null +++ b/interfaces/http/v1/space/dtos/common.go @@ -0,0 +1,16 @@ +package dtos + +import "time" + +type Space struct { + Id string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Icon string `json:"icon"` + IconColor string `json:"icon_color"` + Type string `json:"type"` + MyRole string `json:"my_role,omitempty"` // Role of the current user in this space (owner, admin, editor, viewer) + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/interfaces/http/v1/space/dtos/create_space_request.go b/interfaces/http/v1/space/dtos/create_space_request.go new file mode 100644 index 0000000..1c6267b --- /dev/null +++ b/interfaces/http/v1/space/dtos/create_space_request.go @@ -0,0 +1,33 @@ +package dtos + +type CreateSpaceRequest struct { + Name string `json:"name" validate:"required,min=3,max=100"` + Icon *string `json:"icon,omitempty"` + IconColor *string `json:"icon_color,omitempty"` + Type *string `json:"type,omitempty" validate:"omitempty,oneof=public private"` +} + +type CreateSpaceResponse struct { + SpaceId string `json:"space_id"` +} + +// My spaces list is defined under user DTOs (see interfaces/http/v1/user/dtos) + +type UpdateSpaceRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=100"` + Icon *string `json:"icon,omitempty"` + IconColor *string `json:"icon_color,omitempty"` +} + +type UpdateSpaceResponse struct { + Space Space `json:"space"` +} + +type DeleteSpaceRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` +} + +type DeleteSpaceResponse struct { + SpaceId string `json:"space_id"` +} diff --git a/interfaces/http/v1/space/dtos/permissions.go b/interfaces/http/v1/space/dtos/permissions.go new file mode 100644 index 0000000..10d6959 --- /dev/null +++ b/interfaces/http/v1/space/dtos/permissions.go @@ -0,0 +1,41 @@ +package dtos + +type ListSpacePermissionsRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` +} + +type SpacePermission struct { + Id string `json:"id"` + UserId *string `json:"user_id,omitempty"` + Username string `json:"username,omitempty"` + GroupId *string `json:"group_id,omitempty"` + GroupName string `json:"group_name,omitempty"` + Role string `json:"role"` +} + +type ListSpacePermissionsResponse struct { + Permissions []SpacePermission `json:"permissions"` +} + +type UpsertSpaceUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + UserId string `json:"user_id" validate:"required,uuid4"` + Role string `json:"role" validate:"required,oneof=owner admin editor viewer"` +} + +type UpsertSpaceUserPermissionResponse struct { + Message string `json:"message"` +} + +type DeleteSpaceUserPermissionRequest struct { + SpaceId string `path:"space_id" validate:"required,uuid4"` + UserId string `path:"user_id" validate:"required,uuid4"` +} + +type DeleteSpaceUserPermissionResponse struct { + Message string `json:"message"` +} + + + + diff --git a/interfaces/http/v1/space/handlers.go b/interfaces/http/v1/space/handlers.go new file mode 100644 index 0000000..fbc31c9 --- /dev/null +++ b/interfaces/http/v1/space/handlers.go @@ -0,0 +1,239 @@ +package space + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + spaceDto "github.com/labbs/nexo/application/space/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/interfaces/http/v1/space/dtos" +) + +func (ctrl *Controller) CreateSpace(ctx *fiber.Ctx, req dtos.CreateSpaceRequest) (*dtos.CreateSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.create_space").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + // Default to public if not specified + spaceType := domain.SpaceTypePublic + if req.Type != nil && *req.Type == string(domain.SpaceTypePrivate) { + spaceType = domain.SpaceTypePrivate + } + + result, err := ctrl.SpaceApp.CreateSpace(spaceDto.CreateSpaceInput{ + Name: req.Name, + Icon: req.Icon, + IconColor: req.IconColor, + OwnerId: &authCtx.UserID, + Type: spaceType, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to create space") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to create space", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.CreateSpaceResponse{ + SpaceId: result.Space.Id, + }, nil +} + +// GetMySpaces handled in user routes (/user/my-spaces) + +func (ctrl *Controller) UpdateSpace(ctx *fiber.Ctx, req dtos.UpdateSpaceRequest) (*dtos.UpdateSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.update_space").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.SpaceApp.UpdateSpace(spaceDto.UpdateSpaceInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + Name: req.Name, + Icon: req.Icon, + IconColor: req.IconColor, + }) + if err != nil { + // Permission vs not found vs generic + if err.Error() == "forbidden" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if err.Error() == "not_found" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Space not found", Type: "SPACE_NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update space") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update space", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.UpdateSpaceResponse{Space: dtos.Space{ + Id: result.Space.Id, + Name: result.Space.Name, + Slug: result.Space.Slug, + Icon: result.Space.Icon, + IconColor: result.Space.IconColor, + Type: string(result.Space.Type), + CreatedAt: result.Space.CreatedAt, + UpdatedAt: result.Space.UpdatedAt, + }}, nil +} + +func (ctrl *Controller) DeleteSpace(ctx *fiber.Ctx, req dtos.DeleteSpaceRequest) (*dtos.DeleteSpaceResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.delete_space").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if err := ctrl.SpaceApp.DeleteSpace(spaceDto.DeleteSpaceInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "conflict_children": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusConflict, Details: "Space has active documents", Type: "CONFLICT"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Space not found", Type: "SPACE_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to delete space") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete space", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.DeleteSpaceResponse{SpaceId: req.SpaceId}, nil +} + +func (ctrl *Controller) ListPermissions(ctx *fiber.Ctx, req dtos.ListSpacePermissionsRequest) (*dtos.ListSpacePermissionsResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.list_permissions").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.SpaceApp.ListSpacePermissions(spaceDto.ListSpacePermissionsInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + }) + if err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Space not found", Type: "SPACE_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to list permissions") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list permissions", Type: "INTERNAL_SERVER_ERROR"} + } + } + + resp := &dtos.ListSpacePermissionsResponse{Permissions: make([]dtos.SpacePermission, len(result.Permissions))} + for i, p := range result.Permissions { + resp.Permissions[i] = dtos.SpacePermission{ + Id: p.Id, + UserId: p.UserId, + GroupId: p.GroupId, + Role: string(p.Role), + } + if p.User != nil { + resp.Permissions[i].Username = p.User.Username + } + if p.Group != nil { + resp.Permissions[i].GroupName = p.Group.Name + } + } + return resp, nil +} + +func (ctrl *Controller) UpsertUserPermission(ctx *fiber.Ctx, req dtos.UpsertSpaceUserPermissionRequest) (*dtos.UpsertSpaceUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.upsert_user_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + var role domain.PermissionRole + switch req.Role { + case string(domain.PermissionRoleOwner): + role = domain.PermissionRoleOwner + case string(domain.PermissionRoleAdmin): + role = domain.PermissionRoleAdmin + case string(domain.PermissionRoleEditor): + role = domain.PermissionRoleEditor + default: + role = domain.PermissionRoleViewer + } + + if err := ctrl.SpaceApp.UpsertSpaceUserPermission(spaceDto.UpsertSpaceUserPermissionInput{ + RequesterId: authCtx.UserID, + SpaceId: req.SpaceId, + TargetUserId: req.UserId, + Role: role, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Space not found", Type: "SPACE_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to upsert permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to upsert permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.UpsertSpaceUserPermissionResponse{Message: "permission updated"}, nil +} + +func (ctrl *Controller) DeleteUserPermission(ctx *fiber.Ctx, req dtos.DeleteSpaceUserPermissionRequest) (*dtos.DeleteSpaceUserPermissionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.space.delete_user_permission").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if err := ctrl.SpaceApp.DeleteSpaceUserPermission(spaceDto.DeleteSpaceUserPermissionInput{ + RequesterId: authCtx.UserID, + SpaceId: req.SpaceId, + TargetUserId: req.UserId, + }); err != nil { + switch err.Error() { + case "forbidden": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + case "not_found": + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Space not found", Type: "SPACE_NOT_FOUND"} + default: + logger.Error().Err(err).Msg("failed to delete permission") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete permission", Type: "INTERNAL_SERVER_ERROR"} + } + } + + return &dtos.DeleteSpaceUserPermissionResponse{Message: "permission deleted"}, nil +} diff --git a/interfaces/http/v1/space/router.go b/interfaces/http/v1/space/router.go new file mode 100644 index 0000000..bc2c965 --- /dev/null +++ b/interfaces/http/v1/space/router.go @@ -0,0 +1,60 @@ +package space + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + SpaceApp *space.SpaceApp +} + +func SetupSpaceRouter(controller Controller) { + fiberoapi.Post(controller.FiberOapi, "", controller.CreateSpace, fiberoapi.OpenAPIOptions{ + Summary: "Create a new space", + Description: "Create a new space for the authenticated user", + OperationID: "space.createSpace", + Tags: []string{"Space"}, + }) + + fiberoapi.Put(controller.FiberOapi, "/:space_id", controller.UpdateSpace, fiberoapi.OpenAPIOptions{ + Summary: "Update a space", + Description: "Update space properties", + OperationID: "space.updateSpace", + Tags: []string{"Space"}, + }) + + fiberoapi.Delete(controller.FiberOapi, "/:space_id", controller.DeleteSpace, fiberoapi.OpenAPIOptions{ + Summary: "Delete a space", + Description: "Soft delete a space", + OperationID: "space.deleteSpace", + Tags: []string{"Space"}, + }) + + // Permissions (MVP: user-level) + fiberoapi.Get(controller.FiberOapi, "/:space_id/permissions", controller.ListPermissions, fiberoapi.OpenAPIOptions{ + Summary: "List space permissions", + Description: "List user permissions for a space", + OperationID: "space.listPermissions", + Tags: []string{"Space"}, + }) + + fiberoapi.Put(controller.FiberOapi, "/:space_id/permissions", controller.UpsertUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Upsert user permission on space", + Description: "Create or update a user's permission on a space", + OperationID: "space.upsertUserPermission", + Tags: []string{"Space"}, + }) + + fiberoapi.Delete(controller.FiberOapi, "/:space_id/permissions/:user_id", controller.DeleteUserPermission, fiberoapi.OpenAPIOptions{ + Summary: "Delete user permission on space", + Description: "Remove user's permission from a space", + OperationID: "space.deleteUserPermission", + Tags: []string{"Space"}, + }) +} diff --git a/interfaces/http/v1/user/dtos/add_favorite_request.go b/interfaces/http/v1/user/dtos/add_favorite_request.go new file mode 100644 index 0000000..3fe4892 --- /dev/null +++ b/interfaces/http/v1/user/dtos/add_favorite_request.go @@ -0,0 +1,10 @@ +package dtos + +type AddFavoriteRequest struct { + DocumentId string `path:"document_id" validate:"required,uuid4"` + SpaceId string `path:"space_id" validate:"required,uuid4"` +} + +type AddFavoriteResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/user/dtos/common.go b/interfaces/http/v1/user/dtos/common.go new file mode 100644 index 0000000..eb4f04a --- /dev/null +++ b/interfaces/http/v1/user/dtos/common.go @@ -0,0 +1,12 @@ +package dtos + +import ( + documentDtos "github.com/labbs/nexo/interfaces/http/v1/document/dtos" +) + +type Favorite struct { + Id string `json:"id"` + SpaceId string `json:"space_id"` + Document documentDtos.Document `json:"document"` + Position int `json:"position"` +} diff --git a/interfaces/http/v1/user/dtos/get_my_favorites_request.go b/interfaces/http/v1/user/dtos/get_my_favorites_request.go new file mode 100644 index 0000000..5c96b81 --- /dev/null +++ b/interfaces/http/v1/user/dtos/get_my_favorites_request.go @@ -0,0 +1,5 @@ +package dtos + +type GetMyFavoritesResponse struct { + Favorites []Favorite `json:"favorites"` +} diff --git a/interfaces/http/v1/user/dtos/get_my_spaces_request.go b/interfaces/http/v1/user/dtos/get_my_spaces_request.go new file mode 100644 index 0000000..c59ec49 --- /dev/null +++ b/interfaces/http/v1/user/dtos/get_my_spaces_request.go @@ -0,0 +1,9 @@ +package dtos + +import ( + spaceDtos "github.com/labbs/nexo/interfaces/http/v1/space/dtos" +) + +type GetMySpacesResponse struct { + Spaces []spaceDtos.Space `json:"spaces"` +} diff --git a/interfaces/http/v1/user/dtos/list_users_request.go b/interfaces/http/v1/user/dtos/list_users_request.go new file mode 100644 index 0000000..9aea1ec --- /dev/null +++ b/interfaces/http/v1/user/dtos/list_users_request.go @@ -0,0 +1,19 @@ +package dtos + +// List users (simplified - for use in person picker) + +type ListUsersRequest struct { + Limit int `query:"limit"` + Offset int `query:"offset"` +} + +type UserListItem struct { + Id string `json:"id"` + Username string `json:"username"` + AvatarUrl string `json:"avatar_url"` +} + +type ListUsersResponse struct { + Users []UserListItem `json:"users"` + TotalCount int64 `json:"total_count"` +} diff --git a/interfaces/http/v1/user/dtos/profile_request.go b/interfaces/http/v1/user/dtos/profile_request.go new file mode 100644 index 0000000..0076f22 --- /dev/null +++ b/interfaces/http/v1/user/dtos/profile_request.go @@ -0,0 +1,10 @@ +package dtos + +type ProfileResponse struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Role string `json:"role"` + Preferences map[string]any `json:"preferences,omitempty"` +} diff --git a/interfaces/http/v1/user/dtos/remove_favorite_request.go b/interfaces/http/v1/user/dtos/remove_favorite_request.go new file mode 100644 index 0000000..98f5abe --- /dev/null +++ b/interfaces/http/v1/user/dtos/remove_favorite_request.go @@ -0,0 +1,9 @@ +package dtos + +type RemoveFavoriteRequest struct { + FavoriteId string `path:"favorite_id" validate:"required,uuid4"` +} + +type RemoveFavoriteResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/user/dtos/update_favorite_position_request.go b/interfaces/http/v1/user/dtos/update_favorite_position_request.go new file mode 100644 index 0000000..758c9ad --- /dev/null +++ b/interfaces/http/v1/user/dtos/update_favorite_position_request.go @@ -0,0 +1,15 @@ +package dtos + +type UpdateFavoritePositionRequest struct { + FavoriteId string `path:"favorite_id" validate:"required,uuid4"` + Position int `json:"position" validate:"required,min=0"` +} + +type UpdateFavoritePositionResponse struct { + FavoriteId string `json:"favorite_id"` + Position int `json:"position"` +} + + + + diff --git a/interfaces/http/v1/user/dtos/update_profile_request.go b/interfaces/http/v1/user/dtos/update_profile_request.go new file mode 100644 index 0000000..ef1dce8 --- /dev/null +++ b/interfaces/http/v1/user/dtos/update_profile_request.go @@ -0,0 +1,24 @@ +package dtos + +type UpdateProfileRequest struct { + Username *string `json:"username,omitempty"` + AvatarUrl *string `json:"avatar_url,omitempty"` + Preferences *map[string]any `json:"preferences,omitempty"` +} + +type UpdateProfileResponse struct { + Id string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + AvatarUrl string `json:"avatar_url"` + Preferences map[string]any `json:"preferences,omitempty"` +} + +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" validate:"required,min=8"` + NewPassword string `json:"new_password" validate:"required,min=8"` +} + +type ChangePasswordResponse struct { + Message string `json:"message"` +} diff --git a/interfaces/http/v1/user/dtos/update_space_order_request.go b/interfaces/http/v1/user/dtos/update_space_order_request.go new file mode 100644 index 0000000..b9babe0 --- /dev/null +++ b/interfaces/http/v1/user/dtos/update_space_order_request.go @@ -0,0 +1,9 @@ +package dtos + +type UpdateSpaceOrderRequest struct { + SpaceIds []string `json:"space_ids" validate:"required"` +} + +type UpdateSpaceOrderResponse struct { + SpaceIds []string `json:"space_ids"` +} diff --git a/interfaces/http/v1/user/handlers.go b/interfaces/http/v1/user/handlers.go new file mode 100644 index 0000000..526a7d2 --- /dev/null +++ b/interfaces/http/v1/user/handlers.go @@ -0,0 +1,420 @@ +package user + +import ( + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + spaceDto "github.com/labbs/nexo/application/space/dto" + userDto "github.com/labbs/nexo/application/user/dto" + "github.com/labbs/nexo/domain" + "github.com/labbs/nexo/infrastructure/helpers/mapper" + spaceDtos "github.com/labbs/nexo/interfaces/http/v1/space/dtos" + "github.com/labbs/nexo/interfaces/http/v1/user/dtos" +) + +func (ctrl *Controller) GetProfile(ctx *fiber.Ctx, input struct{}) (*dtos.ProfileResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.get_profile").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + result, err := ctrl.UserApp.GetByUserId(userDto.GetByUserIdInput{UserId: authCtx.UserID}) + if err != nil { + logger.Error().Err(err).Msg("failed to get user by id") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve user", + Type: "INTERNAL_SERVER_ERROR", + } + } + + profile := dtos.ProfileResponse{} + err = mapper.MapStructByFieldNames(result.User, &profile) + if err != nil { + logger.Error().Err(err).Msg("failed to map user to profile") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve profile", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Add role (not auto-mapped because it's a custom type) + profile.Role = string(result.User.Role) + + // Add preferences (not auto-mapped because domain.JSONB != map[string]any for mapper) + if result.User.Preferences != nil { + profile.Preferences = map[string]any(result.User.Preferences) + } + + return &profile, nil +} + +func (ctrl *Controller) GetMySpaces(ctx *fiber.Ctx, input struct{}) (*dtos.GetMySpacesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.get_my_spaces").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + result, err := ctrl.SpaceApp.GetSpacesForUser(spaceDto.GetSpacesForUserInput{UserId: authCtx.UserID}) + if err != nil { + logger.Error().Err(err).Msg("failed to get spaces for user") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve spaces", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Map domain spaces to DTO spaces + spaceDtoList := make([]spaceDtos.Space, len(result.Spaces)) + for i, space := range result.Spaces { + var spaceItem spaceDtos.Space + err := mapper.MapStructByFieldNames(&space, &spaceItem) + if err != nil { + logger.Error().Err(err).Msg("failed to map space to DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process spaces", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Handle the Type field conversion (SpaceType to string) + spaceItem.Type = string(space.Type) + + // Get the user's role in this space + userRole := space.GetUserRole(authCtx.UserID) + if userRole != nil { + spaceItem.MyRole = string(*userRole) + } else if space.Type == domain.SpaceTypePublic { + // For public spaces without explicit permission, user is a viewer + spaceItem.MyRole = string(domain.PermissionRoleViewer) + } + + spaceDtoList[i] = spaceItem + } + + response := &dtos.GetMySpacesResponse{ + Spaces: spaceDtoList, + } + + return response, nil +} + +func (ctrl *Controller) GetMyFavorites(ctx *fiber.Ctx, input struct{}) (*dtos.GetMyFavoritesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.get_my_favorites").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + result, err := ctrl.UserApp.GetMyFavorites(userDto.GetMyFavoritesInput{UserId: authCtx.UserID}) + if err != nil { + logger.Error().Err(err).Msg("failed to get my favorites") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve favorites", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Map domain favorites to DTO favorites + favoriteDtoList := make([]dtos.Favorite, len(result.Favorites)) + for i, favorite := range result.Favorites { + favoriteDto := dtos.Favorite{ + Id: favorite.Id, + SpaceId: favorite.SpaceId, + Position: favorite.Position, + } + + // Manually map the document + if favorite.Document.Id != "" { + err := mapper.MapStructByFieldNames(&favorite.Document, &favoriteDto.Document) + if err != nil { + logger.Error().Err(err).Msg("failed to map favorite document to DTO") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to process favorites", + Type: "INTERNAL_SERVER_ERROR", + } + } + } + + favoriteDtoList[i] = favoriteDto + } + + response := &dtos.GetMyFavoritesResponse{ + Favorites: favoriteDtoList, + } + + return response, nil +} + +func (ctrl *Controller) AddFavorite(ctx *fiber.Ctx, req dtos.AddFavoriteRequest) (*dtos.AddFavoriteResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.add_favorite").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + err = ctrl.UserApp.CreateFavorite(userDto.CreateFavoriteInput{ + DocumentId: req.DocumentId, + SpaceId: req.SpaceId, + UserId: authCtx.UserID, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to add favorite") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to add favorite", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.AddFavoriteResponse{ + Message: "favorite added", + }, nil +} + +func (ctrl *Controller) RemoveFavorite(ctx *fiber.Ctx, req dtos.RemoveFavoriteRequest) (*dtos.RemoveFavoriteResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.remove_favorite").Logger() + + // Get the authenticated user context + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + result, err := ctrl.UserApp.GetFavoriteByIdAndUserId(userDto.GetFavoriteByIdAndUserIdInput{ + FavoriteId: req.FavoriteId, + UserId: authCtx.UserID, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to get favorite by id and user id") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve favorite", + Type: "INTERNAL_SERVER_ERROR", + } + } + + err = ctrl.UserApp.DeleteFavorite(userDto.DeleteFavoriteInput{ + DocumentId: result.Favorite.DocumentId, + UserId: authCtx.UserID, + SpaceId: result.Favorite.SpaceId, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to remove favorite") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to remove favorite", + Type: "INTERNAL_SERVER_ERROR", + } + } + + return &dtos.RemoveFavoriteResponse{ + Message: "favorite removed", + }, nil +} + +func (ctrl *Controller) UpdateFavoritePosition(ctx *fiber.Ctx, req dtos.UpdateFavoritePositionRequest) (*dtos.UpdateFavoritePositionResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.update_favorite_position").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.UserApp.UpdateFavoritePosition(userDto.UpdateFavoritePositionInput{ + UserId: authCtx.UserID, + FavoriteId: req.FavoriteId, + NewPosition: req.Position, + }) + if err != nil { + if err.Error() == "not_found" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Favorite not found", Type: "FAVORITE_NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update favorite position") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update favorite position", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.UpdateFavoritePositionResponse{FavoriteId: result.Favorite.Id, Position: result.Favorite.Position}, nil +} + +func (ctrl *Controller) UpdateProfile(ctx *fiber.Ctx, req dtos.UpdateProfileRequest) (*dtos.UpdateProfileResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.update_profile").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + // Convert preferences if provided + var prefs *domain.JSONB + if req.Preferences != nil { + jsonb := domain.JSONB(*req.Preferences) + prefs = &jsonb + } + + result, err := ctrl.UserApp.UpdateProfile(userDto.UpdateProfileInput{ + UserId: authCtx.UserID, + Username: req.Username, + AvatarUrl: req.AvatarUrl, + Preferences: prefs, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to update profile") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: err.Error(), Type: "UPDATE_PROFILE_FAILED"} + } + + return &dtos.UpdateProfileResponse{ + Id: result.User.Id, + Username: result.User.Username, + Email: result.User.Email, + AvatarUrl: result.User.AvatarUrl, + Preferences: result.User.Preferences, + }, nil +} + +func (ctrl *Controller) ChangePassword(ctx *fiber.Ctx, req dtos.ChangePasswordRequest) (*dtos.ChangePasswordResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.change_password").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.UserApp.ChangePassword(userDto.ChangePasswordInput{ + UserId: authCtx.UserID, + CurrentPassword: req.CurrentPassword, + NewPassword: req.NewPassword, + }) + if err != nil { + if err.Error() == "invalid current password" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Invalid current password", Type: "INVALID_PASSWORD"} + } + logger.Error().Err(err).Msg("failed to change password") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: err.Error(), Type: "CHANGE_PASSWORD_FAILED"} + } + + return &dtos.ChangePasswordResponse{Message: "Password changed successfully"}, nil +} + +func (ctrl *Controller) UpdateSpaceOrder(ctx *fiber.Ctx, req dtos.UpdateSpaceOrderRequest) (*dtos.UpdateSpaceOrderResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.update_space_order").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.UserApp.UpdateSpaceOrder(userDto.UpdateSpaceOrderInput{ + UserId: authCtx.UserID, + SpaceIds: req.SpaceIds, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to update space order") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update space order", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.UpdateSpaceOrderResponse{SpaceIds: result.SpaceIds}, nil +} + +func (ctrl *Controller) ListUsers(ctx *fiber.Ctx, req dtos.ListUsersRequest) (*dtos.ListUsersResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.user.list_users").Logger() + + // Get the authenticated user context (just to ensure user is authenticated) + _, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Details: "Authentication required", + Type: "AUTHENTICATION_REQUIRED", + } + } + + // Set default limit if not provided + limit := req.Limit + if limit <= 0 { + limit = 100 + } + if limit > 500 { + limit = 500 + } + + // Get users from persistence layer + users, totalCount, err := ctrl.UserApp.UserPres.GetAll(limit, req.Offset) + if err != nil { + logger.Error().Err(err).Msg("failed to get users") + return nil, &fiberoapi.ErrorResponse{ + Code: fiber.StatusInternalServerError, + Details: "Failed to retrieve users", + Type: "INTERNAL_SERVER_ERROR", + } + } + + // Map to simplified DTO (only id, username, avatar) + userItems := make([]dtos.UserListItem, len(users)) + for i, user := range users { + userItems[i] = dtos.UserListItem{ + Id: user.Id, + Username: user.Username, + AvatarUrl: user.AvatarUrl, + } + } + + return &dtos.ListUsersResponse{ + Users: userItems, + TotalCount: totalCount, + }, nil +} diff --git a/interfaces/http/v1/user/router.go b/interfaces/http/v1/user/router.go new file mode 100644 index 0000000..63141fc --- /dev/null +++ b/interfaces/http/v1/user/router.go @@ -0,0 +1,86 @@ +package user + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/space" + "github.com/labbs/nexo/application/user" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + UserApp *user.UserApp + SpaceApp *space.SpaceApp +} + +func SetupUserRouter(controller Controller) { + fiberoapi.Get(controller.FiberOapi, "/profile", controller.GetProfile, fiberoapi.OpenAPIOptions{ + Summary: "Get user profile", + Description: "Retrieve the profile of the authenticated user", + OperationID: "user.getProfile", + Tags: []string{"User"}, + }) + fiberoapi.Get(controller.FiberOapi, "/my-spaces", controller.GetMySpaces, fiberoapi.OpenAPIOptions{ + Summary: "Get my spaces", + Description: "Retrieve the spaces of the authenticated user", + OperationID: "user.getMySpaces", + Tags: []string{"User"}, + }) + fiberoapi.Get(controller.FiberOapi, "/my-favorites", controller.GetMyFavorites, fiberoapi.OpenAPIOptions{ + Summary: "Get my favorites", + Description: "Retrieve the favorite items of the authenticated user", + OperationID: "user.getMyFavorites", + Tags: []string{"User"}, + }) + fiberoapi.Post(controller.FiberOapi, "/favorite/:space_id/:document_id", controller.AddFavorite, fiberoapi.OpenAPIOptions{ + Summary: "Add favorite", + Description: "Add a document to the user's favorites", + OperationID: "user.addFavorite", + Tags: []string{"User"}, + }) + fiberoapi.Delete(controller.FiberOapi, "/favorite/:favorite_id", controller.RemoveFavorite, fiberoapi.OpenAPIOptions{ + Summary: "Remove favorite", + Description: "Remove a document from the user's favorites", + OperationID: "user.removeFavorite", + Tags: []string{"User"}, + }) + + fiberoapi.Put(controller.FiberOapi, "/favorite/:favorite_id/position", controller.UpdateFavoritePosition, fiberoapi.OpenAPIOptions{ + Summary: "Update favorite position", + Description: "Reorder favorites by updating a favorite's position", + OperationID: "user.updateFavoritePosition", + Tags: []string{"User"}, + }) + + // Profile management + fiberoapi.Put(controller.FiberOapi, "/profile", controller.UpdateProfile, fiberoapi.OpenAPIOptions{ + Summary: "Update user profile", + Description: "Update the authenticated user's profile (username, avatar, preferences)", + OperationID: "user.updateProfile", + Tags: []string{"User"}, + }) + fiberoapi.Post(controller.FiberOapi, "/change-password", controller.ChangePassword, fiberoapi.OpenAPIOptions{ + Summary: "Change password", + Description: "Change the authenticated user's password", + OperationID: "user.changePassword", + Tags: []string{"User"}, + }) + + // Space order preferences + fiberoapi.Put(controller.FiberOapi, "/preferences/space-order", controller.UpdateSpaceOrder, fiberoapi.OpenAPIOptions{ + Summary: "Update space order", + Description: "Update the order of spaces in the sidebar (user preference)", + OperationID: "user.updateSpaceOrder", + Tags: []string{"User", "Preferences"}, + }) + + fiberoapi.Get(controller.FiberOapi, "/list", controller.ListUsers, fiberoapi.OpenAPIOptions{ + Summary: "List users", + Description: "Get a simplified list of all users (id, username, avatar) for use in person pickers", + OperationID: "user.listUsers", + Tags: []string{"User"}, + }) +} diff --git a/interfaces/http/v1/webhook/controller.go b/interfaces/http/v1/webhook/controller.go new file mode 100644 index 0000000..4009934 --- /dev/null +++ b/interfaces/http/v1/webhook/controller.go @@ -0,0 +1,15 @@ +package webhook + +import ( + fiberoapi "github.com/labbs/fiber-oapi" + "github.com/labbs/nexo/application/webhook" + "github.com/labbs/nexo/infrastructure/config" + "github.com/rs/zerolog" +) + +type Controller struct { + Config config.Config + Logger zerolog.Logger + FiberOapi *fiberoapi.OApiGroup + WebhookApp *webhook.WebhookApp +} diff --git a/interfaces/http/v1/webhook/dtos/webhook.go b/interfaces/http/v1/webhook/dtos/webhook.go new file mode 100644 index 0000000..4079d8b --- /dev/null +++ b/interfaces/http/v1/webhook/dtos/webhook.go @@ -0,0 +1,109 @@ +package dtos + +import "time" + +// Request DTOs + +type EmptyRequest struct{} + +type CreateWebhookRequest struct { + Name string `json:"name"` + Url string `json:"url"` + SpaceId *string `json:"space_id,omitempty"` + Events []string `json:"events"` +} + +type UpdateWebhookRequest struct { + WebhookId string `path:"webhook_id"` + Name *string `json:"name,omitempty"` + Url *string `json:"url,omitempty"` + Events *[]string `json:"events,omitempty"` + Active *bool `json:"active,omitempty"` +} + +type DeleteWebhookRequest struct { + WebhookId string `path:"webhook_id"` +} + +type GetWebhookRequest struct { + WebhookId string `path:"webhook_id"` +} + +type GetDeliveriesRequest struct { + WebhookId string `path:"webhook_id"` + Limit int `query:"limit"` +} + +// Response DTOs + +type MessageResponse struct { + Message string `json:"message"` +} + +type CreateWebhookResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + Secret string `json:"secret"` + Events []string `json:"events"` + Active bool `json:"active"` +} + +type WebhookItem struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + SpaceId *string `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + Events []string `json:"events"` + Active bool `json:"active"` + LastError string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + CreatedAt time.Time `json:"created_at"` +} + +type ListWebhooksResponse struct { + Webhooks []WebhookItem `json:"webhooks"` +} + +type GetWebhookResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Url string `json:"url"` + Secret string `json:"secret"` + SpaceId *string `json:"space_id,omitempty"` + SpaceName *string `json:"space_name,omitempty"` + Events []string `json:"events"` + Active bool `json:"active"` + LastError string `json:"last_error,omitempty"` + LastErrorAt *time.Time `json:"last_error_at,omitempty"` + SuccessCount int `json:"success_count"` + FailureCount int `json:"failure_count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DeliveryItem struct { + Id string `json:"id"` + Event string `json:"event"` + StatusCode int `json:"status_code"` + Success bool `json:"success"` + Duration int `json:"duration_ms"` + CreatedAt time.Time `json:"created_at"` +} + +type GetDeliveriesResponse struct { + Deliveries []DeliveryItem `json:"deliveries"` +} + +// Available events for reference +type AvailableEventsResponse struct { + Events []EventInfo `json:"events"` +} + +type EventInfo struct { + Event string `json:"event"` + Description string `json:"description"` +} diff --git a/interfaces/http/v1/webhook/handlers.go b/interfaces/http/v1/webhook/handlers.go new file mode 100644 index 0000000..5bf5e53 --- /dev/null +++ b/interfaces/http/v1/webhook/handlers.go @@ -0,0 +1,256 @@ +package webhook + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + fiberoapi "github.com/labbs/fiber-oapi" + webhookDto "github.com/labbs/nexo/application/webhook/dto" + "github.com/labbs/nexo/interfaces/http/v1/webhook/dtos" +) + +func (ctrl *Controller) ListWebhooks(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.ListWebhooksResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.list").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.WebhookApp.ListWebhooks(webhookDto.ListWebhooksInput{ + UserId: authCtx.UserID, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to list webhooks") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to list webhooks", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.ListWebhooksResponse{Webhooks: make([]dtos.WebhookItem, len(result.Webhooks))} + for i, w := range result.Webhooks { + resp.Webhooks[i] = dtos.WebhookItem{ + Id: w.Id, + Name: w.Name, + Url: w.Url, + SpaceId: w.SpaceId, + SpaceName: w.SpaceName, + Events: w.Events, + Active: w.Active, + LastError: w.LastError, + LastErrorAt: w.LastErrorAt, + SuccessCount: w.SuccessCount, + FailureCount: w.FailureCount, + CreatedAt: w.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) CreateWebhook(ctx *fiber.Ctx, req dtos.CreateWebhookRequest) (*dtos.CreateWebhookResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.create").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + if req.Name == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "Name is required", Type: "BAD_REQUEST"} + } + + if req.Url == "" { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "URL is required", Type: "BAD_REQUEST"} + } + + if len(req.Events) == 0 { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusBadRequest, Details: "At least one event is required", Type: "BAD_REQUEST"} + } + + result, err := ctrl.WebhookApp.CreateWebhook(webhookDto.CreateWebhookInput{ + UserId: authCtx.UserID, + SpaceId: req.SpaceId, + Name: req.Name, + Url: req.Url, + Events: req.Events, + }) + if err != nil { + logger.Error().Err(err).Msg("failed to create webhook") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to create webhook", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.CreateWebhookResponse{ + Id: result.Id, + Name: result.Name, + Url: result.Url, + Secret: result.Secret, + Events: result.Events, + Active: result.Active, + }, nil +} + +func (ctrl *Controller) GetWebhook(ctx *fiber.Ctx, req dtos.GetWebhookRequest) (*dtos.GetWebhookResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.get").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + result, err := ctrl.WebhookApp.GetWebhook(webhookDto.GetWebhookInput{ + UserId: authCtx.UserID, + WebhookId: req.WebhookId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Webhook not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get webhook") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get webhook", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.GetWebhookResponse{ + Id: result.Id, + Name: result.Name, + Url: result.Url, + Secret: result.Secret, + SpaceId: result.SpaceId, + SpaceName: result.SpaceName, + Events: result.Events, + Active: result.Active, + LastError: result.LastError, + LastErrorAt: result.LastErrorAt, + SuccessCount: result.SuccessCount, + FailureCount: result.FailureCount, + CreatedAt: result.CreatedAt, + UpdatedAt: result.UpdatedAt, + }, nil +} + +func (ctrl *Controller) UpdateWebhook(ctx *fiber.Ctx, req dtos.UpdateWebhookRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.update").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.WebhookApp.UpdateWebhook(webhookDto.UpdateWebhookInput{ + UserId: authCtx.UserID, + WebhookId: req.WebhookId, + Name: req.Name, + Url: req.Url, + Events: req.Events, + Active: req.Active, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Webhook not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to update webhook") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to update webhook", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Webhook updated"}, nil +} + +func (ctrl *Controller) DeleteWebhook(ctx *fiber.Ctx, req dtos.DeleteWebhookRequest) (*dtos.MessageResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.delete").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + err = ctrl.WebhookApp.DeleteWebhook(webhookDto.DeleteWebhookInput{ + UserId: authCtx.UserID, + WebhookId: req.WebhookId, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Webhook not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to delete webhook") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to delete webhook", Type: "INTERNAL_SERVER_ERROR"} + } + + return &dtos.MessageResponse{Message: "Webhook deleted"}, nil +} + +func (ctrl *Controller) GetDeliveries(ctx *fiber.Ctx, req dtos.GetDeliveriesRequest) (*dtos.GetDeliveriesResponse, *fiberoapi.ErrorResponse) { + requestId := ctx.Locals("requestid").(string) + logger := ctrl.Logger.With().Str("request_id", requestId).Str("component", "http.api.v1.webhook.deliveries").Logger() + + authCtx, err := fiberoapi.GetAuthContext(ctx) + if err != nil { + logger.Error().Err(err).Msg("failed to get auth context") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusUnauthorized, Details: "Authentication required", Type: "AUTHENTICATION_REQUIRED"} + } + + limit := req.Limit + if limit <= 0 { + limit = 20 + } + + result, err := ctrl.WebhookApp.GetDeliveries(webhookDto.GetDeliveriesInput{ + UserId: authCtx.UserID, + WebhookId: req.WebhookId, + Limit: limit, + }) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusForbidden, Details: "Forbidden", Type: "FORBIDDEN"} + } + if strings.Contains(err.Error(), "not found") { + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusNotFound, Details: "Webhook not found", Type: "NOT_FOUND"} + } + logger.Error().Err(err).Msg("failed to get deliveries") + return nil, &fiberoapi.ErrorResponse{Code: fiber.StatusInternalServerError, Details: "Failed to get deliveries", Type: "INTERNAL_SERVER_ERROR"} + } + + resp := &dtos.GetDeliveriesResponse{Deliveries: make([]dtos.DeliveryItem, len(result.Deliveries))} + for i, d := range result.Deliveries { + resp.Deliveries[i] = dtos.DeliveryItem{ + Id: d.Id, + Event: d.Event, + StatusCode: d.StatusCode, + Success: d.Success, + Duration: d.Duration, + CreatedAt: d.CreatedAt, + } + } + + return resp, nil +} + +func (ctrl *Controller) GetAvailableEvents(ctx *fiber.Ctx, _ dtos.EmptyRequest) (*dtos.AvailableEventsResponse, *fiberoapi.ErrorResponse) { + events := []dtos.EventInfo{ + {Event: "document.created", Description: "Triggered when a document is created"}, + {Event: "document.updated", Description: "Triggered when a document is updated"}, + {Event: "document.deleted", Description: "Triggered when a document is deleted"}, + {Event: "comment.created", Description: "Triggered when a comment is created"}, + {Event: "comment.resolved", Description: "Triggered when a comment is resolved"}, + {Event: "space.created", Description: "Triggered when a space is created"}, + {Event: "space.updated", Description: "Triggered when a space is updated"}, + } + + return &dtos.AvailableEventsResponse{Events: events}, nil +} diff --git a/interfaces/http/v1/webhook/router.go b/interfaces/http/v1/webhook/router.go new file mode 100644 index 0000000..97d953a --- /dev/null +++ b/interfaces/http/v1/webhook/router.go @@ -0,0 +1,54 @@ +package webhook + +import fiberoapi "github.com/labbs/fiber-oapi" + +func SetupWebhookRouter(ctrl Controller) { + fiberoapi.Get(ctrl.FiberOapi, "/", ctrl.ListWebhooks, fiberoapi.OpenAPIOptions{ + Summary: "List webhooks", + Description: "List all webhooks for the authenticated user", + OperationID: "webhook.list", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Post(ctrl.FiberOapi, "/", ctrl.CreateWebhook, fiberoapi.OpenAPIOptions{ + Summary: "Create webhook", + Description: "Create a new webhook", + OperationID: "webhook.create", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/events", ctrl.GetAvailableEvents, fiberoapi.OpenAPIOptions{ + Summary: "Get available events", + Description: "Get list of available webhook events", + OperationID: "webhook.events", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:webhook_id", ctrl.GetWebhook, fiberoapi.OpenAPIOptions{ + Summary: "Get webhook", + Description: "Get a specific webhook by ID", + OperationID: "webhook.get", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Put(ctrl.FiberOapi, "/:webhook_id", ctrl.UpdateWebhook, fiberoapi.OpenAPIOptions{ + Summary: "Update webhook", + Description: "Update an existing webhook", + OperationID: "webhook.update", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Delete(ctrl.FiberOapi, "/:webhook_id", ctrl.DeleteWebhook, fiberoapi.OpenAPIOptions{ + Summary: "Delete webhook", + Description: "Delete a webhook", + OperationID: "webhook.delete", + Tags: []string{"Webhooks"}, + }) + + fiberoapi.Get(ctrl.FiberOapi, "/:webhook_id/deliveries", ctrl.GetDeliveries, fiberoapi.OpenAPIOptions{ + Summary: "Get webhook deliveries", + Description: "Get delivery history for a webhook", + OperationID: "webhook.deliveries", + Tags: []string{"Webhooks"}, + }) +} diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..2936e32 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -e + +./tmp/main migration -c config.yaml +exec ./tmp/main server -c config.yaml \ No newline at end of file diff --git a/ui b/ui new file mode 160000 index 0000000..2e1aa3a --- /dev/null +++ b/ui @@ -0,0 +1 @@ +Subproject commit 2e1aa3a2c51db2a777b19f7e5d4824d6da7831bc