ALL 0.1.0 Code

This commit is contained in:
2026-02-28 04:21:27 +00:00
commit 7958510989
76 changed files with 17135 additions and 0 deletions

BIN
server/bin/openboard Executable file

Binary file not shown.

246
server/cmd/api/main.go Normal file
View File

@@ -0,0 +1,246 @@
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/handlers"
"github.com/fpmb/server/internal/middleware"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/websocket/v2"
"github.com/joho/godotenv"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
func startDueDateReminder() {
ticker := time.NewTicker(1 * time.Hour)
go func() {
runDueDateReminder()
for range ticker.C {
runDueDateReminder()
}
}()
}
func runDueDateReminder() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
now := time.Now()
thresholds := []int{1, 3}
for _, days := range thresholds {
windowStart := now.Add(time.Duration(days)*24*time.Hour - 30*time.Minute)
windowEnd := now.Add(time.Duration(days)*24*time.Hour + 30*time.Minute)
cursor, err := database.GetCollection("cards").Find(ctx, bson.M{
"due_date": bson.M{"$gte": windowStart, "$lte": windowEnd},
})
if err != nil {
continue
}
var cards []models.Card
cursor.All(ctx, &cards)
cursor.Close(ctx)
for _, card := range cards {
for _, email := range card.Assignees {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&user); err != nil {
continue
}
cutoff := now.Add(-24 * time.Hour)
count, _ := database.GetCollection("notifications").CountDocuments(ctx, bson.M{
"user_id": user.ID,
"type": "due_soon",
"card_id": card.ID,
"created_at": bson.M{"$gte": cutoff},
})
if count > 0 {
continue
}
msg := fmt.Sprintf("Task \"%s\" is due in %d day(s)", card.Title, days)
n := &models.Notification{
ID: primitive.NewObjectID(),
UserID: user.ID,
Type: "due_soon",
Message: msg,
ProjectID: card.ProjectID,
CardID: card.ID,
Read: false,
CreatedAt: now,
}
database.GetCollection("notifications").InsertOne(ctx, n)
}
}
}
}
func main() {
err := godotenv.Load()
if err != nil {
log.Println("No .env file found, using system environment variables")
}
database.Connect()
startDueDateReminder()
app := fiber.New(fiber.Config{
AppName: "FPMB API",
})
app.Use(logger.New(logger.Config{
Next: func(c *fiber.Ctx) bool {
return len(c.Path()) >= 5 && c.Path()[:5] == "/_app" ||
c.Path() == "/favicon.ico" ||
len(c.Path()) >= 7 && c.Path()[:7] == "/fonts/"
},
}))
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
}))
api := app.Group("/api")
api.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"status": "ok", "message": "FPMB API is running"})
})
auth := api.Group("/auth")
auth.Post("/register", handlers.Register)
auth.Post("/login", handlers.Login)
auth.Post("/refresh", handlers.RefreshToken)
auth.Post("/logout", middleware.Protected(), handlers.Logout)
users := api.Group("/users", middleware.Protected())
users.Get("/me", handlers.GetMe)
users.Put("/me", handlers.UpdateMe)
users.Put("/me/password", handlers.ChangePassword)
users.Get("/search", handlers.SearchUsers)
users.Post("/me/avatar", handlers.UploadUserAvatar)
users.Get("/me/avatar", handlers.ServeUserAvatar)
users.Get("/me/files", handlers.ListUserFiles)
users.Post("/me/files/folder", handlers.CreateUserFolder)
users.Post("/me/files/upload", handlers.UploadUserFile)
users.Get("/me/api-keys", handlers.ListAPIKeys)
users.Post("/me/api-keys", handlers.CreateAPIKey)
users.Delete("/me/api-keys/:keyId", handlers.RevokeAPIKey)
teams := api.Group("/teams", middleware.Protected())
teams.Get("/", handlers.ListTeams)
teams.Post("/", handlers.CreateTeam)
teams.Get("/:teamId", handlers.GetTeam)
teams.Put("/:teamId", handlers.UpdateTeam)
teams.Delete("/:teamId", handlers.DeleteTeam)
teams.Get("/:teamId/members", handlers.ListTeamMembers)
teams.Post("/:teamId/members/invite", handlers.InviteTeamMember)
teams.Put("/:teamId/members/:userId", handlers.UpdateTeamMemberRole)
teams.Delete("/:teamId/members/:userId", handlers.RemoveTeamMember)
teams.Get("/:teamId/projects", handlers.ListTeamProjects)
teams.Post("/:teamId/projects", handlers.CreateProject)
teams.Get("/:teamId/events", handlers.ListTeamEvents)
teams.Post("/:teamId/events", handlers.CreateTeamEvent)
teams.Get("/:teamId/docs", handlers.ListDocs)
teams.Post("/:teamId/docs", handlers.CreateDoc)
teams.Get("/:teamId/files", handlers.ListTeamFiles)
teams.Post("/:teamId/files/folder", handlers.CreateTeamFolder)
teams.Post("/:teamId/files/upload", handlers.UploadTeamFile)
teams.Post("/:teamId/avatar", handlers.UploadTeamAvatar)
teams.Get("/:teamId/avatar", handlers.ServeTeamAvatar)
teams.Post("/:teamId/banner", handlers.UploadTeamBanner)
teams.Get("/:teamId/banner", handlers.ServeTeamBanner)
teams.Get("/:teamId/chat", handlers.ListChatMessages)
projects := api.Group("/projects", middleware.Protected())
projects.Get("/", handlers.ListProjects)
projects.Post("/", handlers.CreatePersonalProject)
projects.Get("/:projectId", handlers.GetProject)
projects.Put("/:projectId", handlers.UpdateProject)
projects.Put("/:projectId/archive", handlers.ArchiveProject)
projects.Delete("/:projectId", handlers.DeleteProject)
projects.Get("/:projectId/members", handlers.ListProjectMembers)
projects.Post("/:projectId/members", handlers.AddProjectMember)
projects.Put("/:projectId/members/:userId", handlers.UpdateProjectMemberRole)
projects.Delete("/:projectId/members/:userId", handlers.RemoveProjectMember)
projects.Get("/:projectId/board", handlers.GetBoard)
projects.Post("/:projectId/columns", handlers.CreateColumn)
projects.Put("/:projectId/columns/:columnId", handlers.UpdateColumn)
projects.Put("/:projectId/columns/:columnId/position", handlers.ReorderColumn)
projects.Delete("/:projectId/columns/:columnId", handlers.DeleteColumn)
projects.Post("/:projectId/columns/:columnId/cards", handlers.CreateCard)
projects.Get("/:projectId/events", handlers.ListProjectEvents)
projects.Post("/:projectId/events", handlers.CreateProjectEvent)
projects.Get("/:projectId/files", handlers.ListFiles)
projects.Post("/:projectId/files/folder", handlers.CreateFolder)
projects.Post("/:projectId/files/upload", handlers.UploadFile)
projects.Get("/:projectId/webhooks", handlers.ListWebhooks)
projects.Post("/:projectId/webhooks", handlers.CreateWebhook)
projects.Get("/:projectId/whiteboard", handlers.GetWhiteboard)
projects.Put("/:projectId/whiteboard", handlers.SaveWhiteboard)
cards := api.Group("/cards", middleware.Protected())
cards.Put("/:cardId", handlers.UpdateCard)
cards.Put("/:cardId/move", handlers.MoveCard)
cards.Delete("/:cardId", handlers.DeleteCard)
events := api.Group("/events", middleware.Protected())
events.Put("/:eventId", handlers.UpdateEvent)
events.Delete("/:eventId", handlers.DeleteEvent)
notifications := api.Group("/notifications", middleware.Protected())
notifications.Get("/", handlers.ListNotifications)
notifications.Put("/read-all", handlers.MarkAllNotificationsRead)
notifications.Put("/:notifId/read", handlers.MarkNotificationRead)
notifications.Delete("/:notifId", handlers.DeleteNotification)
docs := api.Group("/docs", middleware.Protected())
docs.Get("/:docId", handlers.GetDoc)
docs.Put("/:docId", handlers.UpdateDoc)
docs.Delete("/:docId", handlers.DeleteDoc)
files := api.Group("/files", middleware.Protected())
files.Get("/:fileId/download", handlers.DownloadFile)
files.Delete("/:fileId", handlers.DeleteFile)
webhooks := api.Group("/webhooks", middleware.Protected())
webhooks.Put("/:webhookId", handlers.UpdateWebhook)
webhooks.Put("/:webhookId/toggle", handlers.ToggleWebhook)
webhooks.Delete("/:webhookId", handlers.DeleteWebhook)
app.Use("/ws", func(c *fiber.Ctx) error {
if websocket.IsWebSocketUpgrade(c) {
return c.Next()
}
return fiber.ErrUpgradeRequired
})
app.Get("/ws/whiteboard/:id", websocket.New(handlers.WhiteboardWS))
app.Get("/ws/team/:id/chat", websocket.New(handlers.TeamChatWS))
app.Static("/", "../build")
app.Get("/*", func(c *fiber.Ctx) error {
if len(c.Path()) > 4 && c.Path()[:4] == "/api" {
return c.Status(404).JSON(fiber.Map{"error": "Not Found"})
}
return c.SendFile("../build/index.html")
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Printf("Server starting on port %s", port)
log.Fatal(app.Listen(":" + port))
}

36
server/go.mod Normal file
View File

@@ -0,0 +1,36 @@
module github.com/fpmb/server
go 1.24.0
require (
github.com/gofiber/fiber/v2 v2.52.12
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/joho/godotenv v1.5.1
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/crypto v0.48.0
)
require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/fasthttp/websocket v1.5.3 // indirect
github.com/gofiber/websocket/v2 v2.2.1 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
)

85
server/go.sum Normal file
View File

@@ -0,0 +1,85 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
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/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw=
github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w=
github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
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.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -0,0 +1,50 @@
package database
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
var DB *mongo.Database
var Client *mongo.Client
func Connect() {
uri := os.Getenv("MONGO_URI")
if uri == "" {
uri = "mongodb://localhost:27017"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
clientOptions := options.Client().ApplyURI(uri)
client, err := mongo.Connect(ctx, clientOptions)
if err != nil {
log.Fatal("Failed to connect to MongoDB: ", err)
}
err = client.Ping(ctx, nil)
if err != nil {
log.Fatal("Failed to ping MongoDB: ", err)
}
fmt.Println("Successfully connected to MongoDB!")
Client = client
dbName := os.Getenv("MONGO_DB_NAME")
if dbName == "" {
dbName = "fpmb"
}
DB = client.Database(dbName)
}
func GetCollection(collectionName string) *mongo.Collection {
return DB.Collection(collectionName)
}

View File

@@ -0,0 +1,165 @@
package handlers
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)
// generateAPIKey returns a 32-byte random hex token (64 chars) prefixed with "fpmb_".
func generateAPIKey() (raw string, hashed string, err error) {
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return
}
raw = "fpmb_" + hex.EncodeToString(b)
sum := sha256.Sum256([]byte(raw))
hashed = hex.EncodeToString(sum[:])
return
}
// ListAPIKeys returns all non-revoked API keys for the current user (without exposing hashes).
func ListAPIKeys(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.GetCollection("api_keys").Find(ctx, bson.M{
"user_id": userID,
"revoked_at": bson.M{"$exists": false},
})
if err != nil {
return c.JSON([]fiber.Map{})
}
defer cursor.Close(ctx)
var keys []models.APIKey
cursor.All(ctx, &keys)
// Strip the hash before returning.
type SafeKey struct {
ID string `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
Prefix string `json:"prefix"`
LastUsed *time.Time `json:"last_used,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
result := []SafeKey{}
for _, k := range keys {
result = append(result, SafeKey{
ID: k.ID.Hex(),
Name: k.Name,
Scopes: k.Scopes,
Prefix: k.Prefix,
LastUsed: k.LastUsed,
CreatedAt: k.CreatedAt,
})
}
return c.JSON(result)
}
// CreateAPIKey generates a new API key and stores its hash.
func CreateAPIKey(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Scopes []string `json:"scopes"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
}
if len(body.Scopes) == 0 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "at least one scope is required"})
}
// Validate scopes.
valid := map[string]bool{
"read:projects": true, "write:projects": true,
"read:boards": true, "write:boards": true,
"read:teams": true, "write:teams": true,
"read:files": true, "write:files": true,
"read:notifications": true,
}
for _, s := range body.Scopes {
if !valid[s] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "unknown scope: " + s})
}
}
raw, hashed, err := generateAPIKey()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate key"})
}
now := time.Now()
key := models.APIKey{
ID: primitive.NewObjectID(),
UserID: userID,
Name: body.Name,
Scopes: body.Scopes,
KeyHash: hashed,
Prefix: raw[:10], // "fpmb_" + first 5 chars of random
CreatedAt: now,
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := database.GetCollection("api_keys").InsertOne(ctx, key); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to store key"})
}
// Return the raw key only once.
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"id": key.ID.Hex(),
"name": key.Name,
"scopes": key.Scopes,
"prefix": key.Prefix,
"key": raw,
"created_at": key.CreatedAt,
})
}
// RevokeAPIKey soft-deletes an API key belonging to the current user.
func RevokeAPIKey(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
keyID, err := primitive.ObjectIDFromHex(c.Params("keyId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid key ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
res, err := database.GetCollection("api_keys").UpdateOne(ctx,
bson.M{"_id": keyID, "user_id": userID},
bson.M{"$set": bson.M{"revoked_at": now}},
)
if err != nil || res.MatchedCount == 0 {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Key not found"})
}
return c.JSON(fiber.Map{"message": "Key revoked"})
}

View File

@@ -0,0 +1,199 @@
package handlers
import (
"context"
"os"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/middleware"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"golang.org/x/crypto/bcrypt"
)
func jwtSecret() []byte {
s := os.Getenv("JWT_SECRET")
if s == "" {
s = "changeme-jwt-secret"
}
return []byte(s)
}
func jwtRefreshSecret() []byte {
s := os.Getenv("JWT_REFRESH_SECRET")
if s == "" {
s = "changeme-refresh-secret"
}
return []byte(s)
}
func generateTokens(user *models.User) (string, string, error) {
accessClaims := &middleware.JWTClaims{
UserID: user.ID.Hex(),
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString(jwtSecret())
if err != nil {
return "", "", err
}
refreshClaims := &middleware.JWTClaims{
UserID: user.ID.Hex(),
Email: user.Email,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString(jwtRefreshSecret())
if err != nil {
return "", "", err
}
return accessToken, refreshToken, nil
}
func Register(c *fiber.Ctx) error {
var body struct {
Name string `json:"name"`
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" || body.Email == "" || body.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name, email and password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
existing := col.FindOne(ctx, bson.M{"email": body.Email})
if existing.Err() == nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "Email already in use"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.Password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
}
now := time.Now()
user := &models.User{
ID: primitive.NewObjectID(),
Name: body.Name,
Email: body.Email,
PasswordHash: string(hash),
CreatedAt: now,
UpdatedAt: now,
}
if _, err := col.InsertOne(ctx, user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create user"})
}
access, refresh, err := generateTokens(user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
"user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email},
})
}
func Login(c *fiber.Ctx) error {
var body struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Email == "" || body.Password == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email and password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var user models.User
err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&user)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.Password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid credentials"})
}
access, refresh, err := generateTokens(&user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": refresh,
"user": fiber.Map{"id": user.ID, "name": user.Name, "email": user.Email},
})
}
func RefreshToken(c *fiber.Ctx) error {
var body struct {
RefreshToken string `json:"refresh_token"`
}
if err := c.BodyParser(&body); err != nil || body.RefreshToken == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "refresh_token is required"})
}
claims := &middleware.JWTClaims{}
token, err := jwt.ParseWithClaims(body.RefreshToken, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return jwtRefreshSecret(), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired refresh token"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
userID, err := primitive.ObjectIDFromHex(claims.UserID)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid token claims"})
}
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "User not found"})
}
access, newRefresh, err := generateTokens(&user)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate tokens"})
}
return c.JSON(fiber.Map{
"access_token": access,
"refresh_token": newRefresh,
})
}
func Logout(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "Logged out successfully"})
}

View File

@@ -0,0 +1,484 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func GetBoard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
colCursor, err := database.GetCollection("board_columns").Find(ctx,
bson.M{"project_id": projectID},
options.Find().SetSort(bson.M{"position": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch columns"})
}
defer colCursor.Close(ctx)
var columns []models.BoardColumn
colCursor.All(ctx, &columns)
type ColumnWithCards struct {
models.BoardColumn
Cards []models.Card `json:"cards"`
}
result := []ColumnWithCards{}
for _, col := range columns {
cardCursor, err := database.GetCollection("cards").Find(ctx,
bson.M{"column_id": col.ID},
options.Find().SetSort(bson.M{"position": 1}))
if err != nil {
result = append(result, ColumnWithCards{BoardColumn: col, Cards: []models.Card{}})
continue
}
var cards []models.Card
cardCursor.All(ctx, &cards)
cardCursor.Close(ctx)
if cards == nil {
cards = []models.Card{}
}
result = append(result, ColumnWithCards{BoardColumn: col, Cards: cards})
}
return c.JSON(fiber.Map{"project_id": projectID, "columns": result})
}
func CreateColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Title string `json:"title"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
count, _ := database.GetCollection("board_columns").CountDocuments(ctx, bson.M{"project_id": projectID})
now := time.Now()
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Title: body.Title,
Position: int(count),
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
return c.Status(fiber.StatusCreated).JSON(col)
}
func UpdateColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Title string `json:"title"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
col := database.GetCollection("board_columns")
col.UpdateOne(ctx, bson.M{"_id": columnID, "project_id": projectID}, bson.M{"$set": update})
var column models.BoardColumn
col.FindOne(ctx, bson.M{"_id": columnID}).Decode(&column)
return c.JSON(column)
}
func ReorderColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Position int `json:"position"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("board_columns").UpdateOne(ctx,
bson.M{"_id": columnID, "project_id": projectID},
bson.M{"$set": bson.M{"position": body.Position, "updated_at": time.Now()}},
)
return c.JSON(fiber.Map{"id": columnID, "position": body.Position})
}
func DeleteColumn(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("board_columns").DeleteOne(ctx, bson.M{"_id": columnID, "project_id": projectID})
database.GetCollection("cards").DeleteMany(ctx, bson.M{"column_id": columnID})
return c.JSON(fiber.Map{"message": "Column deleted"})
}
func CreateCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
columnID, err := primitive.ObjectIDFromHex(c.Params("columnId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column ID"})
}
var body struct {
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Color string `json:"color"`
DueDate string `json:"due_date"`
Assignees []string `json:"assignees"`
Subtasks []models.Subtask `json:"subtasks"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
count, _ := database.GetCollection("cards").CountDocuments(ctx, bson.M{"column_id": columnID})
now := time.Now()
if body.Assignees == nil {
body.Assignees = []string{}
}
if body.Subtasks == nil {
body.Subtasks = []models.Subtask{}
}
if body.Priority == "" {
body.Priority = "Medium"
}
if body.Color == "" {
body.Color = "neutral"
}
var dueDate *time.Time
if body.DueDate != "" {
if parsed, parseErr := time.Parse("2006-01-02", body.DueDate); parseErr == nil {
dueDate = &parsed
}
}
card := &models.Card{
ID: primitive.NewObjectID(),
ColumnID: columnID,
ProjectID: projectID,
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
Color: body.Color,
DueDate: dueDate,
Assignees: body.Assignees,
Subtasks: body.Subtasks,
Position: int(count),
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("cards").InsertOne(ctx, card)
for _, email := range card.Assignees {
var assignee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil {
continue
}
if assignee.ID == userID {
continue
}
createNotification(ctx, assignee.ID, "assign",
"You have been assigned to the task \""+card.Title+"\"",
card.ProjectID, card.ID)
}
return c.Status(fiber.StatusCreated).JSON(card)
}
func UpdateCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var existing models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&existing); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, existing.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title *string `json:"title"`
Description *string `json:"description"`
Priority *string `json:"priority"`
Color *string `json:"color"`
DueDate *string `json:"due_date"`
Assignees []string `json:"assignees"`
Subtasks []models.Subtask `json:"subtasks"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != nil {
update["title"] = *body.Title
}
if body.Description != nil {
update["description"] = *body.Description
}
if body.Priority != nil {
update["priority"] = *body.Priority
}
if body.Color != nil {
update["color"] = *body.Color
}
if body.DueDate != nil {
if *body.DueDate == "" {
update["due_date"] = nil
} else if parsed, parseErr := time.Parse("2006-01-02", *body.DueDate); parseErr == nil {
update["due_date"] = parsed
}
}
if body.Assignees != nil {
update["assignees"] = body.Assignees
}
if body.Subtasks != nil {
update["subtasks"] = body.Subtasks
}
col := database.GetCollection("cards")
col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": update})
var card models.Card
col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&card)
if body.Assignees != nil {
existingSet := make(map[string]bool)
for _, e := range existing.Assignees {
existingSet[e] = true
}
for _, email := range body.Assignees {
if existingSet[email] {
continue
}
var assignee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": email}).Decode(&assignee); err != nil {
continue
}
if assignee.ID == userID {
continue
}
createNotification(ctx, assignee.ID, "assign",
"You have been assigned to the task \""+card.Title+"\"",
card.ProjectID, card.ID)
}
}
return c.JSON(card)
}
func MoveCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
var body struct {
ColumnID string `json:"column_id"`
Position int `json:"position"`
}
if err := c.BodyParser(&body); err != nil || body.ColumnID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "column_id is required"})
}
newColumnID, err := primitive.ObjectIDFromHex(body.ColumnID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid column_id"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var card models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, card.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
col := database.GetCollection("cards")
col.UpdateOne(ctx, bson.M{"_id": cardID}, bson.M{"$set": bson.M{
"column_id": newColumnID,
"position": body.Position,
"updated_at": time.Now(),
}})
var updated models.Card
col.FindOne(ctx, bson.M{"_id": cardID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteCard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
cardID, err := primitive.ObjectIDFromHex(c.Params("cardId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid card ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var card models.Card
if err := database.GetCollection("cards").FindOne(ctx, bson.M{"_id": cardID}).Decode(&card); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Card not found"})
}
roleFlags, err := getProjectRole(ctx, card.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("cards").DeleteOne(ctx, bson.M{"_id": cardID})
return c.JSON(fiber.Map{"message": "Card deleted"})
}

View File

@@ -0,0 +1,238 @@
package handlers
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/websocket/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
type chatRoom struct {
clients map[*websocket.Conn]*wsClient
mu sync.RWMutex
}
var chatRooms = struct {
m map[string]*chatRoom
mu sync.RWMutex
}{m: make(map[string]*chatRoom)}
func getChatRoom(teamID string) *chatRoom {
chatRooms.mu.Lock()
defer chatRooms.mu.Unlock()
if room, ok := chatRooms.m[teamID]; ok {
return room
}
room := &chatRoom{clients: make(map[*websocket.Conn]*wsClient)}
chatRooms.m[teamID] = room
return room
}
func (r *chatRoom) broadcast(sender *websocket.Conn, msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
if conn != sender {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
}
func (r *chatRoom) broadcastAll(msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
func (r *chatRoom) onlineUsers() []map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
seen := map[string]bool{}
list := make([]map[string]string, 0)
for _, c := range r.clients {
if !seen[c.userID] {
seen[c.userID] = true
list = append(list, map[string]string{"user_id": c.userID, "name": c.name})
}
}
return list
}
func ListChatMessages(c *fiber.Ctx) error {
teamID := c.Params("teamId")
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid team ID"})
}
limitStr := c.Query("limit", "50")
limit := int64(50)
if l, err := primitive.ParseDecimal128(limitStr); err == nil {
if s := l.String(); s != "" {
if n, err := parseIntFromString(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
}
beforeStr := c.Query("before", "")
filter := bson.M{"team_id": teamOID}
if beforeStr != "" {
if beforeID, err := primitive.ObjectIDFromHex(beforeStr); err == nil {
filter["_id"] = bson.M{"$lt": beforeID}
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
opts := options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(limit)
cursor, err := database.GetCollection("chat_messages").Find(ctx, filter, opts)
if err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to fetch messages"})
}
defer cursor.Close(ctx)
var messages []models.ChatMessage
if err := cursor.All(ctx, &messages); err != nil {
return c.Status(500).JSON(fiber.Map{"error": "Failed to decode messages"})
}
if messages == nil {
messages = []models.ChatMessage{}
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
return c.JSON(messages)
}
func parseIntFromString(s string) (int64, error) {
var n int64
for _, ch := range s {
if ch < '0' || ch > '9' {
break
}
n = n*10 + int64(ch-'0')
}
return n, nil
}
func TeamChatWS(c *websocket.Conn) {
teamID := c.Params("id")
tokenStr := c.Query("token", "")
userName := c.Query("name", "Anonymous")
userID, _, ok := parseWSToken(tokenStr)
if !ok {
_ = c.WriteJSON(map[string]string{"type": "error", "message": "unauthorized"})
_ = c.Close()
return
}
room := getChatRoom(teamID)
client := &wsClient{conn: c, userID: userID, name: userName}
room.mu.Lock()
room.clients[c] = client
room.mu.Unlock()
presenceMsg, _ := json.Marshal(map[string]interface{}{
"type": "presence",
"users": room.onlineUsers(),
})
room.broadcastAll(presenceMsg)
defer func() {
room.mu.Lock()
delete(room.clients, c)
empty := len(room.clients) == 0
room.mu.Unlock()
leaveMsg, _ := json.Marshal(map[string]interface{}{
"type": "presence",
"users": room.onlineUsers(),
})
room.broadcast(nil, leaveMsg)
if empty {
chatRooms.mu.Lock()
delete(chatRooms.m, teamID)
chatRooms.mu.Unlock()
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
// unexpected error
}
break
}
var incoming struct {
Type string `json:"type"`
Content string `json:"content"`
}
if json.Unmarshal(msg, &incoming) != nil {
continue
}
if incoming.Type == "message" {
content := strings.TrimSpace(incoming.Content)
if content == "" || len(content) > 5000 {
continue
}
teamOID, err := primitive.ObjectIDFromHex(teamID)
if err != nil {
continue
}
userOID, err := primitive.ObjectIDFromHex(userID)
if err != nil {
continue
}
chatMsg := models.ChatMessage{
ID: primitive.NewObjectID(),
TeamID: teamOID,
UserID: userOID,
UserName: userName,
Content: content,
CreatedAt: time.Now(),
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_, _ = database.GetCollection("chat_messages").InsertOne(ctx, chatMsg)
cancel()
outMsg, _ := json.Marshal(map[string]interface{}{
"type": "message",
"message": chatMsg,
})
room.broadcastAll(outMsg)
}
if incoming.Type == "typing" {
typingMsg, _ := json.Marshal(map[string]interface{}{
"type": "typing",
"user_id": userID,
"name": userName,
})
room.broadcast(c, typingMsg)
}
}
}

View File

@@ -0,0 +1,221 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListDocs(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("docs").Find(ctx,
bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"updated_at": -1}).SetProjection(bson.M{"content": 0}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch docs"})
}
defer cursor.Close(ctx)
var docs []models.Doc
cursor.All(ctx, &docs)
if docs == nil {
docs = []models.Doc{}
}
return c.JSON(docs)
}
func CreateDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Title is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
doc := &models.Doc{
ID: primitive.NewObjectID(),
TeamID: teamID,
Title: body.Title,
Content: body.Content,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("docs").InsertOne(ctx, doc)
docDir := filepath.Join("../data/teams", teamID.Hex(), "docs")
if err := os.MkdirAll(docDir, 0755); err != nil {
log.Printf("CreateDoc: mkdir %s: %v", docDir, err)
} else {
content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content)
if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil {
log.Printf("CreateDoc: write file: %v", err)
}
}
return c.Status(fiber.StatusCreated).JSON(doc)
}
func GetDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var doc models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
if _, err := getTeamRole(ctx, doc.TeamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
return c.JSON(doc)
}
func UpdateDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var existing models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&existing); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
roleFlags, err := getTeamRole(ctx, existing.TeamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title string `json:"title"`
Content string `json:"content"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
if body.Content != "" {
update["content"] = body.Content
}
col := database.GetCollection("docs")
col.UpdateOne(ctx, bson.M{"_id": docID}, bson.M{"$set": update})
var doc models.Doc
col.FindOne(ctx, bson.M{"_id": docID}).Decode(&doc)
docDir := filepath.Join("../data/teams", existing.TeamID.Hex(), "docs")
if err := os.MkdirAll(docDir, 0755); err != nil {
log.Printf("UpdateDoc: mkdir %s: %v", docDir, err)
} else {
content := fmt.Sprintf("# %s\n\n%s", doc.Title, doc.Content)
if err := os.WriteFile(filepath.Join(docDir, doc.ID.Hex()+".md"), []byte(content), 0644); err != nil {
log.Printf("UpdateDoc: write file: %v", err)
}
}
return c.JSON(doc)
}
func DeleteDoc(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
docID, err := primitive.ObjectIDFromHex(c.Params("docId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid doc ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var doc models.Doc
if err := database.GetCollection("docs").FindOne(ctx, bson.M{"_id": docID}).Decode(&doc); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Doc not found"})
}
roleFlags, err := getTeamRole(ctx, doc.TeamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("docs").DeleteOne(ctx, bson.M{"_id": docID})
mdPath := filepath.Join("../data/teams", doc.TeamID.Hex(), "docs", docID.Hex()+".md")
if err := os.Remove(mdPath); err != nil && !os.IsNotExist(err) {
log.Printf("DeleteDoc: remove file %s: %v", mdPath, err)
}
return c.JSON(fiber.Map{"message": "Doc deleted"})
}

View File

@@ -0,0 +1,285 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListTeamEvents(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"scope_id": teamID, "scope": "org"}
if month := c.Query("month"); month != "" {
filter["date"] = bson.M{"$regex": "^" + month}
}
cursor, err := database.GetCollection("events").Find(ctx, filter,
options.Find().SetSort(bson.M{"date": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"})
}
defer cursor.Close(ctx)
var events []models.Event
cursor.All(ctx, &events)
if events == nil {
events = []models.Event{}
}
return c.JSON(events)
}
func CreateTeamEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
event := &models.Event{
ID: primitive.NewObjectID(),
Title: body.Title,
Date: body.Date,
Time: body.Time,
Color: body.Color,
Description: body.Description,
Scope: "org",
ScopeID: teamID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("events").InsertOne(ctx, event)
return c.Status(fiber.StatusCreated).JSON(event)
}
func ListProjectEvents(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"scope_id": projectID, "scope": "project"}
if month := c.Query("month"); month != "" {
filter["date"] = bson.M{"$regex": "^" + month}
}
cursor, err := database.GetCollection("events").Find(ctx, filter,
options.Find().SetSort(bson.M{"date": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch events"})
}
defer cursor.Close(ctx)
var events []models.Event
cursor.All(ctx, &events)
if events == nil {
events = []models.Event{}
}
return c.JSON(events)
}
func CreateProjectEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
if err := c.BodyParser(&body); err != nil || body.Title == "" || body.Date == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "title and date are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
event := &models.Event{
ID: primitive.NewObjectID(),
Title: body.Title,
Date: body.Date,
Time: body.Time,
Color: body.Color,
Description: body.Description,
Scope: "project",
ScopeID: projectID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("events").InsertOne(ctx, event)
return c.Status(fiber.StatusCreated).JSON(event)
}
func UpdateEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
eventID, err := primitive.ObjectIDFromHex(c.Params("eventId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var event models.Event
if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"})
}
var roleFlags int
var roleErr error
if event.Scope == "org" {
roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID)
} else {
roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID)
}
if roleErr != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Title string `json:"title"`
Date string `json:"date"`
Time string `json:"time"`
Color string `json:"color"`
Description string `json:"description"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Title != "" {
update["title"] = body.Title
}
if body.Date != "" {
update["date"] = body.Date
}
if body.Time != "" {
update["time"] = body.Time
}
if body.Color != "" {
update["color"] = body.Color
}
if body.Description != "" {
update["description"] = body.Description
}
col := database.GetCollection("events")
col.UpdateOne(ctx, bson.M{"_id": eventID}, bson.M{"$set": update})
var updated models.Event
col.FindOne(ctx, bson.M{"_id": eventID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteEvent(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
eventID, err := primitive.ObjectIDFromHex(c.Params("eventId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid event ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var event models.Event
if err := database.GetCollection("events").FindOne(ctx, bson.M{"_id": eventID}).Decode(&event); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Event not found"})
}
var roleFlags int
var roleErr error
if event.Scope == "org" {
roleFlags, roleErr = getTeamRole(ctx, event.ScopeID, userID)
} else {
roleFlags, roleErr = getProjectRole(ctx, event.ScopeID, userID)
}
if roleErr != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("events").DeleteOne(ctx, bson.M{"_id": eventID})
return c.JSON(fiber.Map{"message": "Event deleted"})
}

View File

@@ -0,0 +1,590 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func storageBase(ctx context.Context, projectID primitive.ObjectID) (string, error) {
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return "", err
}
if project.TeamID == primitive.NilObjectID {
return filepath.Join("../data/users", project.CreatedBy.Hex()), nil
}
return filepath.Join("../data/teams", project.TeamID.Hex()), nil
}
func ListFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
log.Printf("ListFiles getProjectRole error: %v (projectID=%s userID=%s)", err, projectID.Hex(), userID.Hex())
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"project_id": projectID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
log.Printf("ListFiles Find error: %v (filter=%v)", err, filter)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base, err := storageBase(ctx, projectID)
if err != nil {
log.Printf("UploadFile storageBase error: %v (projectID=%s)", err, projectID.Hex())
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to resolve storage path"})
}
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func ListTeamFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
filter := bson.M{"team_id": teamID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateTeamFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadTeamFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base := filepath.Join("../data/teams", teamID.Hex(), "files")
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadTeamFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadTeamFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func ListUserFiles(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"user_id": userID}
if parentID := c.Query("parent_id"); parentID != "" {
oid, err := primitive.ObjectIDFromHex(parentID)
if err == nil {
filter["parent_id"] = oid
}
} else {
filter["parent_id"] = bson.M{"$exists": false}
}
cursor, err := database.GetCollection("files").Find(ctx, filter,
options.Find().SetSort(bson.D{{Key: "type", Value: -1}, {Key: "name", Value: 1}}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch files"})
}
defer cursor.Close(ctx)
var files []models.File
cursor.All(ctx, &files)
if files == nil {
files = []models.File{}
}
return c.JSON(files)
}
func CreateUserFolder(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
ParentID string `json:"parent_id"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Name is required"})
}
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
UserID: userID,
Name: body.Name,
Type: "folder",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.ParentID != "" {
if oid, err := primitive.ObjectIDFromHex(body.ParentID); err == nil {
file.ParentID = &oid
}
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func UploadUserFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
base := filepath.Join("../data/users", userID.Hex(), "files")
if err := os.MkdirAll(base, 0755); err != nil {
log.Printf("UploadUserFile MkdirAll error: %v (base=%s)", err, base)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
filename := fh.Filename
ext := filepath.Ext(filename)
stem := filename[:len(filename)-len(ext)]
destPath := filepath.Join(base, filename)
for n := 2; ; n++ {
if _, statErr := os.Stat(destPath); statErr != nil {
break
}
filename = fmt.Sprintf("%s (%d)%s", stem, n, ext)
destPath = filepath.Join(base, filename)
}
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadUserFile SaveFile error: %v (destPath=%s)", err, destPath)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save file"})
}
relPath := destPath[len("../data/"):]
now := time.Now()
file := &models.File{
ID: primitive.NewObjectID(),
UserID: userID,
Name: fh.Filename,
Type: "file",
SizeBytes: fh.Size,
StorageURL: relPath,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
parentID := c.FormValue("parent_id")
if parentID != "" {
if oid, err := primitive.ObjectIDFromHex(parentID); err == nil {
file.ParentID = &oid
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
database.GetCollection("files").InsertOne(ctx, file)
return c.Status(fiber.StatusCreated).JSON(file)
}
func DownloadFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fileID, err := primitive.ObjectIDFromHex(c.Params("fileId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var file models.File
if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"})
}
if file.TeamID != primitive.NilObjectID {
if _, err := getTeamRole(ctx, file.TeamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
} else if file.UserID != primitive.NilObjectID {
if file.UserID != userID {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
} else {
if _, err := getProjectRole(ctx, file.ProjectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
}
if file.Type == "folder" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Cannot download a folder"})
}
diskPath := filepath.Join("../data", file.StorageURL)
if _, err := os.Stat(diskPath); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found on disk"})
}
c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, file.Name))
return c.SendFile(diskPath)
}
func DeleteFile(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fileID, err := primitive.ObjectIDFromHex(c.Params("fileId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid file ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var file models.File
if err := database.GetCollection("files").FindOne(ctx, bson.M{"_id": fileID}).Decode(&file); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "File not found"})
}
var roleFlags int
if file.TeamID != primitive.NilObjectID {
roleFlags, err = getTeamRole(ctx, file.TeamID, userID)
} else if file.UserID != primitive.NilObjectID {
if file.UserID == userID {
roleFlags = RoleOwner
} else {
err = fmt.Errorf("access denied")
}
} else {
roleFlags, err = getProjectRole(ctx, file.ProjectID, userID)
}
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("files").DeleteOne(ctx, bson.M{"_id": fileID})
if file.Type == "folder" {
database.GetCollection("files").DeleteMany(ctx, bson.M{"parent_id": fileID})
} else if file.StorageURL != "" {
os.Remove(filepath.Join("../data", file.StorageURL))
}
return c.JSON(fiber.Map{"message": "Deleted"})
}

View File

@@ -0,0 +1,111 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func createNotification(ctx context.Context, userID primitive.ObjectID, notifType, message string, projectID primitive.ObjectID, cardID primitive.ObjectID) {
n := &models.Notification{
ID: primitive.NewObjectID(),
UserID: userID,
Type: notifType,
Message: message,
ProjectID: projectID,
CardID: cardID,
Read: false,
CreatedAt: time.Now(),
}
database.GetCollection("notifications").InsertOne(ctx, n)
}
func ListNotifications(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
filter := bson.M{"user_id": userID}
if c.Query("read") == "false" {
filter["read"] = false
}
cursor, err := database.GetCollection("notifications").Find(ctx, filter,
options.Find().SetSort(bson.M{"created_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch notifications"})
}
defer cursor.Close(ctx)
var notifications []models.Notification
cursor.All(ctx, &notifications)
if notifications == nil {
notifications = []models.Notification{}
}
return c.JSON(notifications)
}
func MarkNotificationRead(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
notifID, err := primitive.ObjectIDFromHex(c.Params("notifId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").UpdateOne(ctx,
bson.M{"_id": notifID, "user_id": userID},
bson.M{"$set": bson.M{"read": true}},
)
return c.JSON(fiber.Map{"message": "Marked as read"})
}
func MarkAllNotificationsRead(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").UpdateMany(ctx,
bson.M{"user_id": userID, "read": false},
bson.M{"$set": bson.M{"read": true}},
)
return c.JSON(fiber.Map{"message": "All notifications marked as read"})
}
func DeleteNotification(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
notifID, err := primitive.ObjectIDFromHex(c.Params("notifId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid notification ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
database.GetCollection("notifications").DeleteOne(ctx, bson.M{"_id": notifID, "user_id": userID})
return c.JSON(fiber.Map{"message": "Notification deleted"})
}

View File

@@ -0,0 +1,633 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func getProjectRole(ctx context.Context, projectID, userID primitive.ObjectID) (int, error) {
var pm models.ProjectMember
err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": projectID,
"user_id": userID,
}).Decode(&pm)
if err == nil {
return pm.RoleFlags, nil
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return 0, err
}
if project.TeamID == primitive.NilObjectID {
return 0, fiber.ErrForbidden
}
return getTeamRole(ctx, project.TeamID, userID)
}
func ListProjects(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
type ProjectResponse struct {
ID primitive.ObjectID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TeamID primitive.ObjectID `json:"team_id"`
TeamName string `json:"team_name"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
IsPublic bool `json:"is_public"`
IsArchived bool `json:"is_archived"`
UpdatedAt time.Time `json:"updated_at"`
}
result := []ProjectResponse{}
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
cursor.All(ctx, &memberships)
for _, m := range memberships {
var team models.Team
database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team)
projCursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": m.TeamID})
if err != nil {
continue
}
var projects []models.Project
projCursor.All(ctx, &projects)
projCursor.Close(ctx)
for _, p := range projects {
roleFlags := m.RoleFlags
var pm models.ProjectMember
if err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": p.ID,
"user_id": userID,
}).Decode(&pm); err == nil {
roleFlags = pm.RoleFlags
}
result = append(result, ProjectResponse{
ID: p.ID,
Name: p.Name,
Description: p.Description,
TeamID: p.TeamID,
TeamName: team.Name,
RoleFlags: roleFlags,
RoleName: roleName(roleFlags),
IsPublic: p.IsPublic,
IsArchived: p.IsArchived,
UpdatedAt: p.UpdatedAt,
})
}
}
personalCursor, err := database.GetCollection("project_members").Find(ctx, bson.M{
"user_id": userID,
})
if err == nil {
defer personalCursor.Close(ctx)
var pms []models.ProjectMember
personalCursor.All(ctx, &pms)
for _, pm := range pms {
var p models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{
"_id": pm.ProjectID,
"team_id": primitive.NilObjectID,
}).Decode(&p); err != nil {
continue
}
result = append(result, ProjectResponse{
ID: p.ID,
Name: p.Name,
Description: p.Description,
TeamID: p.TeamID,
TeamName: "Personal",
RoleFlags: pm.RoleFlags,
RoleName: roleName(pm.RoleFlags),
IsPublic: p.IsPublic,
IsArchived: p.IsArchived,
UpdatedAt: p.UpdatedAt,
})
}
}
return c.JSON(result)
}
func CreatePersonalProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
project := &models.Project{
ID: primitive.NewObjectID(),
TeamID: primitive.NilObjectID,
Name: body.Name,
Description: body.Description,
IsPublic: body.IsPublic,
IsArchived: false,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"})
}
member := &models.ProjectMember{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
UserID: userID,
RoleFlags: RoleOwner,
AddedAt: now,
}
database.GetCollection("project_members").InsertOne(ctx, member)
defaultColumns := []string{"To Do", "In Progress", "Done"}
for i, title := range defaultColumns {
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
Title: title,
Position: i,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
}
return c.Status(fiber.StatusCreated).JSON(project)
}
func ListTeamProjects(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
teamRole, err := getTeamRole(ctx, teamID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("projects").Find(ctx, bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"updated_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch projects"})
}
defer cursor.Close(ctx)
var projects []models.Project
cursor.All(ctx, &projects)
type ProjectResponse struct {
models.Project
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
}
result := []ProjectResponse{}
for _, p := range projects {
flags := teamRole
var pm models.ProjectMember
if err := database.GetCollection("project_members").FindOne(ctx, bson.M{
"project_id": p.ID, "user_id": userID,
}).Decode(&pm); err == nil {
flags = pm.RoleFlags
}
result = append(result, ProjectResponse{Project: p, RoleFlags: flags, RoleName: roleName(flags)})
}
return c.JSON(result)
}
func CreateProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic bool `json:"is_public"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Project name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
project := &models.Project{
ID: primitive.NewObjectID(),
TeamID: teamID,
Name: body.Name,
Description: body.Description,
IsPublic: body.IsPublic,
IsArchived: false,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("projects").InsertOne(ctx, project); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create project"})
}
defaultColumns := []string{"To Do", "In Progress", "Done"}
for i, title := range defaultColumns {
col := &models.BoardColumn{
ID: primitive.NewObjectID(),
ProjectID: project.ID,
Title: title,
Position: i,
CreatedAt: now,
UpdatedAt: now,
}
database.GetCollection("board_columns").InsertOne(ctx, col)
}
return c.Status(fiber.StatusCreated).JSON(project)
}
func GetProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"})
}
return c.JSON(fiber.Map{
"id": project.ID,
"team_id": project.TeamID,
"name": project.Name,
"description": project.Description,
"visibility": project.Visibility,
"is_public": project.IsPublic,
"is_archived": project.IsArchived,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": project.CreatedAt,
"updated_at": project.UpdatedAt,
})
}
func UpdateProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
Description string `json:"description"`
IsPublic *bool `json:"is_public"`
Visibility string `json:"visibility"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.Description != "" {
update["description"] = body.Description
}
if body.IsPublic != nil {
update["is_public"] = *body.IsPublic
}
if body.Visibility != "" {
update["visibility"] = body.Visibility
update["is_public"] = body.Visibility == "public"
}
col := database.GetCollection("projects")
col.UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": update})
var project models.Project
col.FindOne(ctx, bson.M{"_id": projectID}).Decode(&project)
return c.JSON(project)
}
func ArchiveProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var project models.Project
if err := database.GetCollection("projects").FindOne(ctx, bson.M{"_id": projectID}).Decode(&project); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Project not found"})
}
database.GetCollection("projects").UpdateOne(ctx, bson.M{"_id": projectID}, bson.M{"$set": bson.M{
"is_archived": !project.IsArchived,
"updated_at": time.Now(),
}})
return c.JSON(fiber.Map{"is_archived": !project.IsArchived})
}
func DeleteProject(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleOwner) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete projects"})
}
database.GetCollection("projects").DeleteOne(ctx, bson.M{"_id": projectID})
database.GetCollection("board_columns").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("cards").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("project_members").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("events").DeleteMany(ctx, bson.M{"scope_id": projectID, "scope": "project"})
database.GetCollection("files").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("webhooks").DeleteMany(ctx, bson.M{"project_id": projectID})
database.GetCollection("whiteboards").DeleteMany(ctx, bson.M{"project_id": projectID})
return c.JSON(fiber.Map{"message": "Project deleted"})
}
func ListProjectMembers(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("project_members").Find(ctx, bson.M{"project_id": projectID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"})
}
defer cursor.Close(ctx)
var members []models.ProjectMember
cursor.All(ctx, &members)
type MemberResponse struct {
UserID primitive.ObjectID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
}
result := []MemberResponse{}
for _, m := range members {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil {
continue
}
result = append(result, MemberResponse{
UserID: m.UserID,
Name: user.Name,
Email: user.Email,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
})
}
return c.JSON(result)
}
func AddProjectMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
UserID string `json:"user_id"`
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
targetUserID, err := primitive.ObjectIDFromHex(body.UserID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user_id"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
flags := body.RoleFlags
if flags == 0 {
flags = RoleViewer
}
member := &models.ProjectMember{
ID: primitive.NewObjectID(),
ProjectID: projectID,
UserID: targetUserID,
RoleFlags: flags,
AddedAt: time.Now(),
}
database.GetCollection("project_members").InsertOne(ctx, member)
return c.Status(fiber.StatusCreated).JSON(member)
}
func UpdateProjectMemberRole(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
var body struct {
RoleFlags int `json:"role_flags"`
}
c.BodyParser(&body)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("project_members").UpdateOne(ctx,
bson.M{"project_id": projectID, "user_id": targetUserID},
bson.M{"$set": bson.M{"role_flags": body.RoleFlags}},
)
return c.JSON(fiber.Map{"user_id": targetUserID, "role_flags": body.RoleFlags, "role_name": roleName(body.RoleFlags)})
}
func RemoveProjectMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("project_members").DeleteOne(ctx, bson.M{"project_id": projectID, "user_id": targetUserID})
return c.JSON(fiber.Map{"message": "Member removed"})
}

View File

@@ -0,0 +1,602 @@
package handlers
import (
"context"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
RoleViewer = 1
RoleEditor = 2
RoleAdmin = 4
RoleOwner = 8
)
func hasPermission(userRole, requiredRole int) bool {
return userRole >= requiredRole
}
func roleName(flags int) string {
switch {
case flags&RoleOwner != 0:
return "Owner"
case flags&RoleAdmin != 0:
return "Admin"
case flags&RoleEditor != 0:
return "Editor"
default:
return "Viewer"
}
}
func getTeamRole(ctx context.Context, teamID, userID primitive.ObjectID) (int, error) {
var member models.TeamMember
err := database.GetCollection("team_members").FindOne(ctx, bson.M{
"team_id": teamID,
"user_id": userID,
}).Decode(&member)
if err != nil {
return 0, err
}
return member.RoleFlags, nil
}
func ListTeams(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"user_id": userID})
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch teams"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
if err := cursor.All(ctx, &memberships); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to decode memberships"})
}
type TeamResponse struct {
ID primitive.ObjectID `json:"id"`
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
MemberCount int64 `json:"member_count"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
CreatedAt time.Time `json:"created_at"`
}
result := []TeamResponse{}
for _, m := range memberships {
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": m.TeamID}).Decode(&team); err != nil {
continue
}
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": m.TeamID})
result = append(result, TeamResponse{
ID: team.ID,
Name: team.Name,
WorkspaceID: team.WorkspaceID,
MemberCount: count,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
CreatedAt: team.CreatedAt,
})
}
return c.JSON(result)
}
func CreateTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Team name is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
now := time.Now()
team := &models.Team{
ID: primitive.NewObjectID(),
Name: body.Name,
WorkspaceID: body.WorkspaceID,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if _, err := database.GetCollection("teams").InsertOne(ctx, team); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create team"})
}
member := &models.TeamMember{
ID: primitive.NewObjectID(),
TeamID: team.ID,
UserID: userID,
RoleFlags: RoleOwner,
InvitedBy: userID,
JoinedAt: now,
}
if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add team owner"})
}
return c.Status(fiber.StatusCreated).JSON(team)
}
func GetTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Team not found"})
}
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{
"id": team.ID,
"name": team.Name,
"workspace_id": team.WorkspaceID,
"avatar_url": team.AvatarURL,
"banner_url": team.BannerURL,
"member_count": count,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": team.CreatedAt,
"updated_at": team.UpdatedAt,
})
}
func UpdateTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
WorkspaceID string `json:"workspace_id"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.WorkspaceID != "" {
update["workspace_id"] = body.WorkspaceID
}
col := database.GetCollection("teams")
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": update}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
}
var team models.Team
col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team)
return c.JSON(team)
}
func DeleteTeam(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleOwner) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Only owners can delete teams"})
}
database.GetCollection("teams").DeleteOne(ctx, bson.M{"_id": teamID})
database.GetCollection("team_members").DeleteMany(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{"message": "Team deleted"})
}
func ListTeamMembers(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("team_members").Find(ctx, bson.M{"team_id": teamID},
options.Find().SetSort(bson.M{"joined_at": 1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch members"})
}
defer cursor.Close(ctx)
var memberships []models.TeamMember
cursor.All(ctx, &memberships)
type MemberResponse struct {
ID primitive.ObjectID `json:"id"`
UserID primitive.ObjectID `json:"user_id"`
Name string `json:"name"`
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
RoleName string `json:"role_name"`
JoinedAt time.Time `json:"joined_at"`
}
result := []MemberResponse{}
for _, m := range memberships {
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": m.UserID}).Decode(&user); err != nil {
continue
}
result = append(result, MemberResponse{
ID: m.ID,
UserID: m.UserID,
Name: user.Name,
Email: user.Email,
RoleFlags: m.RoleFlags,
RoleName: roleName(m.RoleFlags),
JoinedAt: m.JoinedAt,
})
}
return c.JSON(result)
}
func InviteTeamMember(c *fiber.Ctx) error {
inviterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
var body struct {
Email string `json:"email"`
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.Email == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Email is required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, inviterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var invitee models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"email": body.Email}).Decode(&invitee); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User with that email not found"})
}
existing := database.GetCollection("team_members").FindOne(ctx, bson.M{"team_id": teamID, "user_id": invitee.ID})
if existing.Err() == nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "User is already a member"})
}
flags := body.RoleFlags
if flags == 0 {
flags = RoleViewer
}
member := &models.TeamMember{
ID: primitive.NewObjectID(),
TeamID: teamID,
UserID: invitee.ID,
RoleFlags: flags,
InvitedBy: inviterID,
JoinedAt: time.Now(),
}
if _, err := database.GetCollection("team_members").InsertOne(ctx, member); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to add member"})
}
var team models.Team
if err := database.GetCollection("teams").FindOne(ctx, bson.M{"_id": teamID}).Decode(&team); err == nil {
createNotification(ctx, invitee.ID, "team_invite",
"You have been invited to team \""+team.Name+"\"",
primitive.NilObjectID, primitive.NilObjectID)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"message": "Member added successfully",
"member": fiber.Map{
"user_id": invitee.ID,
"name": invitee.Name,
"email": invitee.Email,
"role_flags": flags,
"role_name": roleName(flags),
},
})
}
func UpdateTeamMemberRole(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
var body struct {
RoleFlags int `json:"role_flags"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
if _, err := database.GetCollection("team_members").UpdateOne(ctx,
bson.M{"team_id": teamID, "user_id": targetUserID},
bson.M{"$set": bson.M{"role_flags": body.RoleFlags}},
); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update role"})
}
return c.JSON(fiber.Map{
"user_id": targetUserID,
"role_flags": body.RoleFlags,
"role_name": roleName(body.RoleFlags),
})
}
func RemoveTeamMember(c *fiber.Ctx) error {
requesterID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
targetUserID, err := primitive.ObjectIDFromHex(c.Params("userId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid user ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, requesterID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("team_members").DeleteOne(ctx, bson.M{"team_id": teamID, "user_id": targetUserID})
return c.JSON(fiber.Map{"message": "Member removed"})
}
var allowedImageExts = map[string]bool{
".jpg": true,
".jpeg": true,
".png": true,
".gif": true,
".webp": true,
}
func uploadTeamImage(c *fiber.Ctx, imageType string) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
roleFlags, err := getTeamRole(ctx, teamID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if !allowedImageExts[ext] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"})
}
dir := filepath.Join("../data/teams", teamID.Hex())
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("uploadTeamImage MkdirAll error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
existingGlob := filepath.Join(dir, imageType+".*")
if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 {
for _, m := range matches {
os.Remove(m)
}
}
destPath := filepath.Join(dir, imageType+ext)
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("uploadTeamImage SaveFile error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"})
}
imageURL := fmt.Sprintf("/api/teams/%s/%s", teamID.Hex(), imageType)
field := imageType + "_url"
col := database.GetCollection("teams")
if _, err := col.UpdateOne(ctx, bson.M{"_id": teamID}, bson.M{"$set": bson.M{
field: imageURL,
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update team"})
}
var team models.Team
col.FindOne(ctx, bson.M{"_id": teamID}).Decode(&team)
count, _ := database.GetCollection("team_members").CountDocuments(ctx, bson.M{"team_id": teamID})
return c.JSON(fiber.Map{
"id": team.ID,
"name": team.Name,
"workspace_id": team.WorkspaceID,
"avatar_url": team.AvatarURL,
"banner_url": team.BannerURL,
"member_count": count,
"role_flags": roleFlags,
"role_name": roleName(roleFlags),
"created_at": team.CreatedAt,
"updated_at": team.UpdatedAt,
})
}
func UploadTeamAvatar(c *fiber.Ctx) error {
return uploadTeamImage(c, "avatar")
}
func UploadTeamBanner(c *fiber.Ctx) error {
return uploadTeamImage(c, "banner")
}
func serveTeamImage(c *fiber.Ctx, imageType string) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
teamID, err := primitive.ObjectIDFromHex(c.Params("teamId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid team ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getTeamRole(ctx, teamID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
dir := filepath.Join("../data/teams", teamID.Hex())
for ext := range allowedImageExts {
p := filepath.Join(dir, imageType+ext)
if _, err := os.Stat(p); err == nil {
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Image not found"})
}
func ServeTeamAvatar(c *fiber.Ctx) error {
return serveTeamImage(c, "avatar")
}
func ServeTeamBanner(c *fiber.Ctx) error {
return serveTeamImage(c, "banner")
}

View File

@@ -0,0 +1,230 @@
package handlers
import (
"context"
"log"
"os"
"path/filepath"
"strings"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
"golang.org/x/crypto/bcrypt"
)
func GetMe(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var user models.User
if err := database.GetCollection("users").FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
}
return c.JSON(user)
}
func UpdateMe(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.Email != "" {
update["email"] = body.Email
}
if body.AvatarURL != "" {
update["avatar_url"] = body.AvatarURL
}
col := database.GetCollection("users")
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": update}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
}
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"})
}
return c.JSON(user)
}
func SearchUsers(c *fiber.Ctx) error {
q := c.Query("q")
if len(q) < 1 {
return c.JSON([]fiber.Map{})
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
filter := bson.M{
"$or": bson.A{
bson.M{"name": bson.M{"$regex": q, "$options": "i"}},
bson.M{"email": bson.M{"$regex": q, "$options": "i"}},
},
}
cursor, err := database.GetCollection("users").Find(ctx, filter, options.Find().SetLimit(10))
if err != nil {
return c.JSON([]fiber.Map{})
}
defer cursor.Close(ctx)
var users []models.User
cursor.All(ctx, &users)
type UserResult struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
result := []UserResult{}
for _, u := range users {
result = append(result, UserResult{ID: u.ID.Hex(), Name: u.Name, Email: u.Email})
}
return c.JSON(result)
}
func UploadUserAvatar(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
fh, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No file provided"})
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
if !allowedImageExts[ext] {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid image type"})
}
dir := filepath.Join("../data/users", userID.Hex())
if err := os.MkdirAll(dir, 0755); err != nil {
log.Printf("UploadUserAvatar MkdirAll error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create storage directory"})
}
existingGlob := filepath.Join(dir, "avatar.*")
if matches, _ := filepath.Glob(existingGlob); len(matches) > 0 {
for _, m := range matches {
os.Remove(m)
}
}
destPath := filepath.Join(dir, "avatar"+ext)
if err := c.SaveFile(fh, destPath); err != nil {
log.Printf("UploadUserAvatar SaveFile error: %v", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to save image"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
"avatar_url": "/api/users/me/avatar",
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update user"})
}
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch updated user"})
}
return c.JSON(user)
}
func ServeUserAvatar(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
dir := filepath.Join("../data/users", userID.Hex())
for ext := range allowedImageExts {
p := filepath.Join(dir, "avatar"+ext)
if _, err := os.Stat(p); err == nil {
return c.SendFile(p)
}
}
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Avatar not found"})
}
func ChangePassword(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
var body struct {
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
if body.CurrentPassword == "" || body.NewPassword == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "current_password and new_password are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
col := database.GetCollection("users")
var user models.User
if err := col.FindOne(ctx, bson.M{"_id": userID}).Decode(&user); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "User not found"})
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(body.CurrentPassword)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Current password is incorrect"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(body.NewPassword), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to hash password"})
}
if _, err := col.UpdateOne(ctx, bson.M{"_id": userID}, bson.M{"$set": bson.M{
"password_hash": string(hash),
"updated_at": time.Now(),
}}); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to update password"})
}
return c.JSON(fiber.Map{"message": "Password updated successfully"})
}

View File

@@ -0,0 +1,220 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
func ListWebhooks(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
cursor, err := database.GetCollection("webhooks").Find(ctx,
bson.M{"project_id": projectID},
options.Find().SetSort(bson.M{"created_at": -1}))
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch webhooks"})
}
defer cursor.Close(ctx)
var webhooks []models.Webhook
cursor.All(ctx, &webhooks)
if webhooks == nil {
webhooks = []models.Webhook{}
}
return c.JSON(webhooks)
}
func CreateWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url"`
Secret string `json:"secret"`
}
if err := c.BodyParser(&body); err != nil || body.Name == "" || body.URL == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name and url are required"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
wType := body.Type
if wType == "" {
wType = "custom"
}
now := time.Now()
webhook := &models.Webhook{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Name: body.Name,
Type: wType,
URL: body.URL,
Status: "active",
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
if body.Secret != "" {
webhook.SecretHash = body.Secret
}
database.GetCollection("webhooks").InsertOne(ctx, webhook)
return c.Status(fiber.StatusCreated).JSON(webhook)
}
func UpdateWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
var body struct {
Name string `json:"name"`
URL string `json:"url"`
Type string `json:"type"`
}
c.BodyParser(&body)
update := bson.M{"updated_at": time.Now()}
if body.Name != "" {
update["name"] = body.Name
}
if body.URL != "" {
update["url"] = body.URL
}
if body.Type != "" {
update["type"] = body.Type
}
col := database.GetCollection("webhooks")
col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": update})
var updated models.Webhook
col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated)
return c.JSON(updated)
}
func ToggleWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
newStatus := "active"
if wh.Status == "active" {
newStatus = "inactive"
}
col := database.GetCollection("webhooks")
col.UpdateOne(ctx, bson.M{"_id": webhookID}, bson.M{"$set": bson.M{
"status": newStatus,
"updated_at": time.Now(),
}})
var updated models.Webhook
col.FindOne(ctx, bson.M{"_id": webhookID}).Decode(&updated)
return c.JSON(updated)
}
func DeleteWebhook(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
webhookID, err := primitive.ObjectIDFromHex(c.Params("webhookId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid webhook ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wh models.Webhook
if err := database.GetCollection("webhooks").FindOne(ctx, bson.M{"_id": webhookID}).Decode(&wh); err != nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "Webhook not found"})
}
roleFlags, err := getProjectRole(ctx, wh.ProjectID, userID)
if err != nil || !hasPermission(roleFlags, RoleAdmin) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
database.GetCollection("webhooks").DeleteOne(ctx, bson.M{"_id": webhookID})
return c.JSON(fiber.Map{"message": "Webhook deleted"})
}

View File

@@ -0,0 +1,96 @@
package handlers
import (
"context"
"time"
"github.com/fpmb/server/internal/database"
"github.com/fpmb/server/internal/models"
"github.com/gofiber/fiber/v2"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo"
)
func GetWhiteboard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := getProjectRole(ctx, projectID, userID); err != nil {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Access denied"})
}
var wb models.Whiteboard
err = database.GetCollection("whiteboards").FindOne(ctx, bson.M{"project_id": projectID}).Decode(&wb)
if err == mongo.ErrNoDocuments {
return c.JSON(fiber.Map{"id": nil, "project_id": projectID, "data": "", "updated_at": nil})
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to fetch whiteboard"})
}
return c.JSON(wb)
}
func SaveWhiteboard(c *fiber.Ctx) error {
userID, err := primitive.ObjectIDFromHex(c.Locals("user_id").(string))
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid user"})
}
projectID, err := primitive.ObjectIDFromHex(c.Params("projectId"))
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid project ID"})
}
var body struct {
Data string `json:"data"`
}
if err := c.BodyParser(&body); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"})
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
roleFlags, err := getProjectRole(ctx, projectID, userID)
if err != nil || !hasPermission(roleFlags, RoleEditor) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "Insufficient permissions"})
}
now := time.Now()
col := database.GetCollection("whiteboards")
var existing models.Whiteboard
err = col.FindOne(ctx, bson.M{"project_id": projectID}).Decode(&existing)
if err == mongo.ErrNoDocuments {
wb := &models.Whiteboard{
ID: primitive.NewObjectID(),
ProjectID: projectID,
Data: body.Data,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
col.InsertOne(ctx, wb)
return c.JSON(fiber.Map{"id": wb.ID, "project_id": projectID, "updated_at": now})
}
col.UpdateOne(ctx, bson.M{"project_id": projectID}, bson.M{"$set": bson.M{
"data": body.Data,
"updated_at": now,
}})
return c.JSON(fiber.Map{"id": existing.ID, "project_id": projectID, "updated_at": now})
}

View File

@@ -0,0 +1,164 @@
package handlers
import (
"encoding/json"
"log"
"os"
"sync"
"github.com/gofiber/websocket/v2"
"github.com/golang-jwt/jwt/v5"
)
type wsMessage struct {
Type string `json:"type"`
Payload json.RawMessage `json:"payload,omitempty"`
UserID string `json:"user_id,omitempty"`
Name string `json:"name,omitempty"`
X float64 `json:"x,omitempty"`
Y float64 `json:"y,omitempty"`
}
type wsClient struct {
conn *websocket.Conn
userID string
name string
}
type whiteboardRoom struct {
clients map[*websocket.Conn]*wsClient
mu sync.RWMutex
}
var wsRooms = struct {
m map[string]*whiteboardRoom
mu sync.RWMutex
}{m: make(map[string]*whiteboardRoom)}
func getRoom(boardID string) *whiteboardRoom {
wsRooms.mu.Lock()
defer wsRooms.mu.Unlock()
if room, ok := wsRooms.m[boardID]; ok {
return room
}
room := &whiteboardRoom{clients: make(map[*websocket.Conn]*wsClient)}
wsRooms.m[boardID] = room
return room
}
func (r *whiteboardRoom) broadcast(sender *websocket.Conn, msg []byte) {
r.mu.RLock()
defer r.mu.RUnlock()
for conn := range r.clients {
if conn != sender {
_ = conn.WriteMessage(websocket.TextMessage, msg)
}
}
}
func (r *whiteboardRoom) userList() []map[string]string {
r.mu.RLock()
defer r.mu.RUnlock()
list := make([]map[string]string, 0, len(r.clients))
for _, c := range r.clients {
list = append(list, map[string]string{"user_id": c.userID, "name": c.name})
}
return list
}
func parseWSToken(tokenStr string) (userID string, email string, ok bool) {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "changeme-jwt-secret"
}
type claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
c := &claims{}
token, err := jwt.ParseWithClaims(tokenStr, c, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return "", "", false
}
return c.UserID, c.Email, true
}
func WhiteboardWS(c *websocket.Conn) {
boardID := c.Params("id")
tokenStr := c.Query("token", "")
userName := c.Query("name", "Anonymous")
userID, _, ok := parseWSToken(tokenStr)
if !ok {
_ = c.WriteJSON(map[string]string{"type": "error", "payload": "unauthorized"})
_ = c.Close()
return
}
room := getRoom(boardID)
client := &wsClient{conn: c, userID: userID, name: userName}
room.mu.Lock()
room.clients[c] = client
room.mu.Unlock()
joinMsg, _ := json.Marshal(map[string]interface{}{
"type": "join",
"user_id": userID,
"name": userName,
"users": room.userList(),
})
room.broadcast(nil, joinMsg)
selfMsg, _ := json.Marshal(map[string]interface{}{
"type": "users",
"users": room.userList(),
})
_ = c.WriteMessage(websocket.TextMessage, selfMsg)
defer func() {
room.mu.Lock()
delete(room.clients, c)
empty := len(room.clients) == 0
room.mu.Unlock()
leaveMsg, _ := json.Marshal(map[string]interface{}{
"type": "leave",
"user_id": userID,
"name": userName,
"users": room.userList(),
})
room.broadcast(nil, leaveMsg)
if empty {
wsRooms.mu.Lock()
delete(wsRooms.m, boardID)
wsRooms.mu.Unlock()
}
}()
for {
_, msg, err := c.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Printf("WS error board=%s user=%s: %v", boardID, userID, err)
}
break
}
var incoming map[string]interface{}
if json.Unmarshal(msg, &incoming) == nil {
incoming["user_id"] = userID
incoming["name"] = userName
outMsg, _ := json.Marshal(incoming)
room.broadcast(c, outMsg)
}
}
}

View File

@@ -0,0 +1,51 @@
package middleware
import (
"os"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/golang-jwt/jwt/v5"
)
type JWTClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
jwt.RegisteredClaims
}
func Protected() fiber.Handler {
return func(c *fiber.Ctx) error {
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Missing authorization header"})
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid authorization header format"})
}
tokenStr := parts[1]
secret := os.Getenv("JWT_SECRET")
if secret == "" {
secret = "changeme-jwt-secret"
}
claims := &JWTClaims{}
token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fiber.ErrUnauthorized
}
return []byte(secret), nil
})
if err != nil || !token.Valid {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid or expired token"})
}
c.Locals("user_id", claims.UserID)
c.Locals("user_email", claims.Email)
return c.Next()
}
}

View File

@@ -0,0 +1,184 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/bson/primitive"
)
type User struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Email string `bson:"email" json:"email"`
PasswordHash string `bson:"password_hash" json:"-"`
AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Team struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
WorkspaceID string `bson:"workspace_id" json:"workspace_id"`
AvatarURL string `bson:"avatar_url,omitempty" json:"avatar_url,omitempty"`
BannerURL string `bson:"banner_url,omitempty" json:"banner_url,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type TeamMember struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
RoleFlags int `bson:"role_flags" json:"role_flags"`
InvitedBy primitive.ObjectID `bson:"invited_by" json:"invited_by"`
JoinedAt time.Time `bson:"joined_at" json:"joined_at"`
}
type Project struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
Name string `bson:"name" json:"name"`
Description string `bson:"description" json:"description"`
Visibility string `bson:"visibility" json:"visibility"`
IsPublic bool `bson:"is_public" json:"is_public"`
IsArchived bool `bson:"is_archived" json:"is_archived"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type ProjectMember struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
RoleFlags int `bson:"role_flags" json:"role_flags"`
AddedAt time.Time `bson:"added_at" json:"added_at"`
}
type BoardColumn struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Position int `bson:"position" json:"position"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Subtask struct {
ID int `bson:"id" json:"id"`
Text string `bson:"text" json:"text"`
Done bool `bson:"done" json:"done"`
}
type Card struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ColumnID primitive.ObjectID `bson:"column_id" json:"column_id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Title string `bson:"title" json:"title"`
Description string `bson:"description" json:"description"`
Priority string `bson:"priority" json:"priority"`
Color string `bson:"color" json:"color"`
DueDate *time.Time `bson:"due_date,omitempty" json:"due_date,omitempty"`
Assignees []string `bson:"assignees" json:"assignees"`
Subtasks []Subtask `bson:"subtasks" json:"subtasks"`
Position int `bson:"position" json:"position"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Event struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
Title string `bson:"title" json:"title"`
Date string `bson:"date" json:"date"`
Time string `bson:"time" json:"time"`
Color string `bson:"color" json:"color"`
Description string `bson:"description" json:"description"`
Scope string `bson:"scope" json:"scope"`
ScopeID primitive.ObjectID `bson:"scope_id" json:"scope_id"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Notification struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
Type string `bson:"type" json:"type"`
Message string `bson:"message" json:"message"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
CardID primitive.ObjectID `bson:"card_id,omitempty" json:"card_id,omitempty"`
Read bool `bson:"read" json:"read"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type Doc struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
Title string `bson:"title" json:"title"`
Content string `bson:"content" json:"content"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type File struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id,omitempty" json:"project_id,omitempty"`
TeamID primitive.ObjectID `bson:"team_id,omitempty" json:"team_id,omitempty"`
UserID primitive.ObjectID `bson:"user_id,omitempty" json:"user_id,omitempty"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
SizeBytes int64 `bson:"size_bytes" json:"size_bytes"`
ParentID *primitive.ObjectID `bson:"parent_id,omitempty" json:"parent_id,omitempty"`
StorageURL string `bson:"storage_url,omitempty" json:"storage_url,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Webhook struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Name string `bson:"name" json:"name"`
Type string `bson:"type" json:"type"`
URL string `bson:"url" json:"url"`
SecretHash string `bson:"secret_hash,omitempty" json:"-"`
Status string `bson:"status" json:"status"`
LastTriggered *time.Time `bson:"last_triggered,omitempty" json:"last_triggered,omitempty"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type Whiteboard struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
ProjectID primitive.ObjectID `bson:"project_id" json:"project_id"`
Data string `bson:"data" json:"data"`
CreatedBy primitive.ObjectID `bson:"created_by" json:"created_by"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
UpdatedAt time.Time `bson:"updated_at" json:"updated_at"`
}
type APIKey struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
Name string `bson:"name" json:"name"`
Scopes []string `bson:"scopes" json:"scopes"`
KeyHash string `bson:"key_hash" json:"-"`
Prefix string `bson:"prefix" json:"prefix"`
LastUsed *time.Time `bson:"last_used,omitempty" json:"last_used,omitempty"`
RevokedAt *time.Time `bson:"revoked_at,omitempty" json:"revoked_at,omitempty"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
type ChatMessage struct {
ID primitive.ObjectID `bson:"_id,omitempty" json:"id"`
TeamID primitive.ObjectID `bson:"team_id" json:"team_id"`
UserID primitive.ObjectID `bson:"user_id" json:"user_id"`
UserName string `bson:"user_name" json:"user_name"`
Content string `bson:"content" json:"content"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}