diff --git a/controllers/calendar.go b/controllers/calendar.go new file mode 100644 index 0000000..da18b13 --- /dev/null +++ b/controllers/calendar.go @@ -0,0 +1,187 @@ +package controllers + +import ( + "database/sql" + "errors" + "fmt" + + "git.gnous.eu/Rick/calendrier/models" + "git.gnous.eu/Rick/calendrier/services" + "github.com/gofiber/fiber/v2" +) + +// @Summary Retourne tous les calendriers +// @Tag calendar +// @Param name query string false "Nom du calendrier" +// @Produce json +// @Success 200 {array} models.Calendar +// @Failure 401 "Token mal formaté" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendars [get] +func GetCalendars(c *fiber.Ctx) error { + name := c.Query("name") + res, err := services.GetAllCalendar(name) + + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"err": err.Error()}) + } else { + return c.Status(fiber.StatusOK).JSON(res) + } +} + +// @Summary Retourne les informations sur un calendrier +// @Tag calendar +// @Produce json +// @Success 200 {array} models.Calendar +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id} [get] +func GetCalendar(c *fiber.Ctx) error { + id, err := c.ParamsInt("id") + + if err == nil { + tmp, err := services.GetCalendarById(id) + + if errors.Is(err, sql.ErrNoRows) { + return c.SendStatus(fiber.StatusNotFound) + } else if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"err": err.Error()}) + } else { + return c.Status(fiber.StatusOK).JSON(tmp) + } + } + + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"err": err.Error()}) +} + +// @Summary Retourne tous les évènements d'un calendrier +// @Tag calendar +// @Param name query string false "Nom de l'évènement" +// @Produce json +// @Success 200 {array} models.Event +// @Failure 400 "Date mal formatée ou incohérente" +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/events [get] +func GetCalendarEvents(c *fiber.Ctx) error { + return nil +} + +// @Summary Créer un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/ [post] +func PostCalendar(c *fiber.Ctx) error { + tmp := new(models.Calendar) + err := c.BodyParser(tmp) + + fmt.Println(tmp) + + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"err": err.Error()}) + } + + err = services.CreateCalendar(tmp) + + if err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"err": err.Error()}) + } else { + return c.SendStatus(fiber.StatusOK) + } +} + +// @Summary Modifie un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id} [patch] +func PatchCalendar(c *fiber.Ctx) error { + return nil +} + +// @Summary Supprime un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id} [delete] +func DeleteCalendar(c *fiber.Ctx) error { + return nil +} + +// @Summary Ajoute un évènements à un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier ou évènement introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/event/{id} [post] +func PostCalendarEvent(c *fiber.Ctx) error { + return nil +} + +// @Summary Partage un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier ou introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/share [post] +func PostCalendarShare(c *fiber.Ctx) error { + return nil +} + +// @Summary Supprime un partage de calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier ou lien introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/share/{id} [delete] +func DeleteCalendarShare(c *fiber.Ctx) error { + return nil +} + +// @Summary Ajoute un utilisateur dans un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier ou utilisateur introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/user/{id} [post] +func PostCalendarUser(c *fiber.Ctx) error { + return nil +} + +// @Summary Supprime un utilisateur dans un calendrier +// @Tag calendar +// @Success 200 +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier ou utilisateur introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/user/{id} [delete] +func DeleteCalendarUser(c *fiber.Ctx) error { + return nil +} + +// @Summary Change la visibilité d'un calendrier +// @Tag calendar +// @Success 200 +// @Accept json +// @Param is_public body bool true "La visiblité à mettre" +// @Failure 401 "Token mal formaté" +// @Failure 404 "Calendrier introuvable" +// @Failure 500 "Erreur dans la base de données" +// @Router /calendar/{id}/visibility [put] +/* +func PutVisibilityCalendar(c *fiber.Ctx) error { + return nil +} +*/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..90cd9a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +version: '3' + +services: + db: + image: postgres:14-alpine + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + volumes: + - calendar_db:/var/lib/postgresql/data + - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql + #- ./sql/populate_debug.sql:/docker-entrypoint-initdb.d/populate.sql + ports: + - 5432:5432 + + db-ui: + image: dpage/pgadmin4 + environment: + PGADMIN_DEFAULT_EMAIL: test@test.fr + PGADMIN_DEFAULT_PASSWORD: test + ports: + - 8081:80 + +volumes: + calendar_db: {} diff --git a/env.example b/env.example new file mode 100644 index 0000000..87f56e6 --- /dev/null +++ b/env.example @@ -0,0 +1,6 @@ +DB_USER="test" +DB_PASSWD="test" +DB_ADDRESS="localhost:5432" +DB_DATABASE="pif" + +JWT_SECRET="secret" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..927e0c4 --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module git.gnous.eu/Rick/calendrier + +go 1.21.5 + +require ( + github.com/gofiber/fiber/v2 v2.52.0 + github.com/uptrace/bun v1.1.17 + github.com/uptrace/bun/dialect/pgdialect v1.1.17 + github.com/uptrace/bun/driver/pgdriver v1.1.17 +) + +require ( + github.com/andybalholm/brotli v1.0.5 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/klauspost/compress v1.17.0 // 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.15 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // 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/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + golang.org/x/crypto v0.18.0 // indirect + golang.org/x/sys v0.16.0 // indirect + mellium.im/sasl v0.3.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f5ed533 --- /dev/null +++ b/go.sum @@ -0,0 +1,53 @@ +github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +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/gofiber/fiber/v2 v2.52.0 h1:S+qXi7y+/Pgvqq4DrSmREGiFwtB7Bu6+QFLuIHYw/UE= +github.com/gofiber/fiber/v2 v2.52.0/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +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.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.1.17 h1:qxBaEIo0hC/8O3O6GrMDKxqyT+mw5/s0Pn/n6xjyGIk= +github.com/uptrace/bun v1.1.17/go.mod h1:hATAzivtTIRsSJR4B8AXR+uABqnQxr3myKDKEf5iQ9U= +github.com/uptrace/bun/dialect/pgdialect v1.1.17 h1:NsvFVHAx1Az6ytlAD/B6ty3cVE6j9Yp82bjqd9R9hOs= +github.com/uptrace/bun/dialect/pgdialect v1.1.17/go.mod h1:fLBDclNc7nKsZLzNjFL6BqSdgJzbj2HdnyOnLoDvAME= +github.com/uptrace/bun/driver/pgdriver v1.1.17 h1:hLj6WlvSZk5x45frTQnJrYtyhvgI6CA4r7gYdJ0gpn8= +github.com/uptrace/bun/driver/pgdriver v1.1.17/go.mod h1:c9fa6FiiQjOe9mCaJC9NmFUE6vCGKTEsqrtLjPNz+kk= +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/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +mellium.im/sasl v0.3.1 h1:wE0LW6g7U83vhvxjC1IY8DnXM+EU095yeo8XClvCdfo= +mellium.im/sasl v0.3.1/go.mod h1:xm59PUYpZHhgQ9ZqoJ5QaCqzWMi8IeS49dhp6plPCzw= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ae26ebe --- /dev/null +++ b/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "git.gnous.eu/Rick/calendrier/routes" + "github.com/gofiber/fiber/v2" +) + +func main() { + app := fiber.New() + routes.SetupApi(app) + err := app.Listen(":8080") + if err != nil { + panic(err) + } +} diff --git a/models/calendar.go b/models/calendar.go new file mode 100644 index 0000000..08845c9 --- /dev/null +++ b/models/calendar.go @@ -0,0 +1,18 @@ +package models + +import "github.com/uptrace/bun" + +type Calendar struct { + bun.BaseModel `bun:"table:calendar"` + Id int `json:"id" bun:"id,pk,autoincrement"` + Name string `json:"name"` + Url string `json:"url"` + IsPublic bool `json:"is_public"` +} + +/* +type CalendarUrl struct { + Url string `json:"url"` + CanWrite bool `json:"can_write"` +} +*/ diff --git a/routes/api.go b/routes/api.go new file mode 100644 index 0000000..3697c45 --- /dev/null +++ b/routes/api.go @@ -0,0 +1,28 @@ +package routes + +import ( + "git.gnous.eu/Rick/calendrier/controllers" + "github.com/gofiber/fiber/v2" +) + +func SetupApi(app *fiber.App) { + api := app.Group("/api/v1") + + api.Get("/calendars", controllers.GetCalendars) + api.Get("/calendar/:id", controllers.GetCalendar) + + api.Post("/calendar", controllers.PostCalendar) + + /* + api.Put("/calendar/:id/visibility", controllers.PutVisibilityCalendar) + api.Delete("/calendar/:id", controllers.DeleteCalendar) + + api.Get("/calendar/:id/events", controllers.GetCalendarEvents) + api.Post("/calendar/:id/event/:id", controllers.PostCalendarEvent) + + api.Get("/event/:id", controllers.GetEvent) + api.Post("/event/:id", controllers.PostEvent) + api.Put("/event/:id", controllers.PutEvent) + api.Delete("/event/:id", controllers.DeleteEvent) + */ +} diff --git a/services/calendar.go b/services/calendar.go new file mode 100644 index 0000000..c8443a0 --- /dev/null +++ b/services/calendar.go @@ -0,0 +1,155 @@ +package services + +import ( + "context" + + "git.gnous.eu/Rick/calendrier/models" +) + +func GetAllCalendar(name string) ([]models.Calendar, error) { + var err error + slice := []models.Calendar{} + db := get() + ctx := context.Background() + + err = db.NewSelect().Table("calendar").Where("is_public = TRUE").Where("name like ?", "%"+name+"%").Scan(ctx, &slice) + + return slice, err +} + +func GetCalendarById(id int) (models.Calendar, error) { + var res models.Calendar + ctx := context.Background() + db := get() + + err := db.NewSelect().Table("calendar").Where("id = ?", id).Scan(ctx, &res) + + return res, err +} + +func CreateCalendar(c *models.Calendar) error { + ctx := context.Background() + db := get() + + _, err := db.NewInsert().Model(c).Exec(ctx) + + return err +} + +/* +func GetCalendarsByOwner(user_id int) ([]models.Calendar, error) { + ctx := context.Background() + conn, err := get() + defer close(conn) + + var res []models.Calendar + + if err != nil { + return res, err + } + + rows, err = conn.Query(ctx, "select * from calendar where owner = $1", user_id) + + if err != nil { + return res, err + } + + tmp := new(models.Calendar) + for rows.Next() { + err = rows.Scan(&res.Id, &res.Name, &res.Url, &res.IsPublic, &res.Owner) + + if err != nil { + return res, err + } + + if res != nil { + res = append(res, *tmp) + } else { + res = []models.Calendar{*tmp} + } + } + + return res, nil +} + +func SetPublic(id int, is_public bool) error { + ctx := context.Background() + conn, err := get() + defer close(conn) + + if err != nil { + return err + } + + tx, err := conn.Begin(ctx) + + if err != nil { + return err + } + + _, err = tx.Exec(ctx, "update calendar set is_public = $2 where id = $1", id, is_public) + + if err != nil { + return err + } + + tx.Commit(ctx) + return nil +} + +func UpdateCalendar(c *models.Calendar) error { + ctx := context.Background() + conn, err := get() + defer close(conn) + + if err != nil { + return err + } + + tx, err := conn.Begin(ctx) + + if err != nil { + return err + } + + _, err = tx.Exec(ctx, "update calendar set name = $2, url = $3, owner = $4, is_public = $5 where id = $1", c.Id, c.Name, c.Url, c.Owner, c.IsPublic) + + if err != nil { + return err + } + + tx.Commit(ctx) + return nil +} + +func DeleteCalendar(id int) error { + ctx := context.Background() + conn, err := get() + defer close(conn) + + if err != nil { + return err + } + + tx, err := conn.Begin(ctx) + + if err != nil { + return err + } + + _, err := tx.Exec(ctx, "delete from calendar where id = $1", id) + + if err != nil { + return err + } + + _, err := tx.Exec(ctx, "delete from event_in_calendar where calendar_id = $1", id) + + if err != nil { + return err + } + + tx.Commit(ctx) + return nil +} +*/ diff --git a/services/connect.go b/services/connect.go new file mode 100644 index 0000000..202e9b9 --- /dev/null +++ b/services/connect.go @@ -0,0 +1,44 @@ +package services + +import ( + "database/sql" + "os" + + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" +) + +func get() *bun.DB { + connector := pgdriver.NewConnector( + pgdriver.WithInsecure(true), + pgdriver.WithAddr(os.Getenv("DB_ADDRESS")), + pgdriver.WithUser(os.Getenv("DB_USER")), + pgdriver.WithPassword(os.Getenv("DB_PASSWD")), + pgdriver.WithDatabase(os.Getenv("DB_DATABASE")), + ) + + ret := bun.NewDB(sql.OpenDB(connector), pgdialect.New()) + return ret +} + +/* +func get() (*pgx.Conn, error) { + uri := "postgres://" + os.Getenv("DB_USER") + ":" + os.Getenv("DB_PASSWD") + + "@" + os.Getenv("DB_ADDRESS") + "/" + os.Getenv("DB_DATABASE") + conn, err := pgx.Connect(context.Background(), uri) + if err != nil { + return nil, err + } + + return conn, nil +} +*/ + +/* +func close(conn *pgx.Conn) { + if conn != nil { + conn.Close(context.Background()) + } +} +*/ diff --git a/sql/init.sql b/sql/init.sql index 466a812..89236d0 100644 --- a/sql/init.sql +++ b/sql/init.sql @@ -12,7 +12,7 @@ CREATE TABLE IF NOT EXISTS c_user ( CREATE TABLE IF NOT EXISTS calendar ( id SERIAL PRIMARY KEY NOT NULL, name TEXT NOT NULL, - url TEXT, + url TEXT UNIQUE NOT NULL, is_public BOOLEAN DEFAULT TRUE );