refactor(publications,projects): renames projects to publications

This commit is contained in:
Guz
2025-11-18 13:19:55 -03:00
parent bf817a14c7
commit 6daaaaa6fd
15 changed files with 381 additions and 340 deletions

View File

@@ -151,9 +151,9 @@ func (app *app) setup() error {
return fmt.Errorf("app: failed to start token repository: %w", err)
}
projectRepository, err := repository.NewProject(app.ctx, app.db, app.logger.WithGroup("repository.project"), app.assert)
publicationRepository, err := repository.NewPublication(app.ctx, app.db, app.logger.WithGroup("repository.publication"), app.assert)
if err != nil {
return fmt.Errorf("app: failed to start project repository: %w", err)
return fmt.Errorf("app: failed to start publication repository: %w", err)
}
permissionRepository, err := repository.NewPermissions(app.ctx, app.db, app.logger.WithGroup("repository.permission"), app.assert)
@@ -169,12 +169,12 @@ func (app *app) setup() error {
Logger: app.logger.WithGroup("service.token"),
Assertions: app.assert,
})
projectService := service.NewProject(projectRepository, permissionRepository, app.logger.WithGroup("service.project"), app.assert)
publicationService := service.NewPublication(publicationRepository, permissionRepository, app.logger.WithGroup("service.publication"), app.assert)
app.handler, err = router.New(router.Config{
UserService: userService,
TokenService: tokenService,
ProjectService: projectService,
UserService: userService,
TokenService: tokenService,
PublicationService: publicationService,
Templates: app.templates,
DisableCache: app.developmentMode,

View File

@@ -120,7 +120,7 @@ const (
PermissionAuthor Permissions = 0x1111111111111111 // "author"
PermissionAdminDelete Permissions = 0x1000000000000000 // "admin.delete" -----
PermissionAdminAll Permissions = 0x0111110000000001 // "admin.all"
PermissionAdminProject Permissions = 0x0100000000000000 // "admin.project"
PermissionAdminPublication Permissions = 0x0100000000000000 // "admin.publication"
PermissionAdminMembers Permissions = 0x0010000000000000 // "admin.members"
PermissionEditAll Permissions = 0x0000001111111111 // "edit.all" ---------
PermissionEditPages Permissions = 0x0000000100000000 // "edit.pages"
@@ -134,7 +134,7 @@ const (
var PermissionLabels = map[Permissions]string{
PermissionAuthor: "author",
PermissionAdminDelete: "admin.delete",
PermissionAdminProject: "admin.project",
PermissionAdminPublication: "admin.publication",
PermissionAdminMembers: "admin.members",
PermissionEditPages: "edit.pages",
PermissionEditInteractions: "edit.interactions",

View File

@@ -6,16 +6,16 @@ import (
"github.com/google/uuid"
)
type Project struct {
type Publication struct {
ID uuid.UUID // Must be unique, represented as base64 string in URLs
Title string // Must not be empty
DateCreated time.Time
DateUpdated time.Time
}
var _ Model = (*Project)(nil)
var _ Model = (*Publication)(nil)
func (p Project) Validate() error {
func (p Publication) Validate() error {
errs := []error{}
if len(p.ID) == 0 {
errs = append(errs, ErrZeroValue{Name: "UUID"})
@@ -31,7 +31,7 @@ func (p Project) Validate() error {
}
if len(errs) > 0 {
return ErrInvalidModel{Name: "Project", Errors: errs}
return ErrInvalidModel{Name: "Publication", Errors: errs}
}
return nil

View File

@@ -17,7 +17,7 @@ type Permissions struct {
baseRepostiory
}
// Must be initiated after [User] and [Project]
// Must be initiated after [User] and [Publication]
func NewPermissions(
ctx context.Context,
db *sql.DB,
@@ -32,17 +32,17 @@ func NewPermissions(
}
q := fmt.Sprintf(`
CREATE TABLE IF NOT EXISTS project_permissions (
project_id TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS publication_permissions (
publication_id TEXT NOT NULL,
user_id TEXT NOT NULL,
permissions_value INTEGER NOT NULL DEFAULT '0',
_permissions_text TEXT NOT NULL DEFAULT '', -- For display purposes only, may not always be up-to-date
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(project_id, user_id)
FOREIGN KEY(project_id)
REFERENCES projects (id)
PRIMARY KEY(publication_id, user_id)
FOREIGN KEY(publication_id)
REFERENCES publications (id)
ON DELETE CASCADE
ON UPDATE RESTRICT,
FOREIGN KEY(user_id)
@@ -58,13 +58,13 @@ func NewPermissions(
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
return nil, errors.Join(errors.New("unable to create publication tables"), err)
}
return &Permissions{baseRepostiory: b}, nil
}
func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Create(publication, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -75,21 +75,21 @@ func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permis
}
q := `
INSERT INTO project_permissions (project_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:project_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
INSERT INTO publication_permissions (publication_id, user_id, permissions_value, _permissions_text, created_at, updated_at)
VALUES (:publication_id, :user_id, :permissions_value, :permissions_text, :created_at, :updated_at)
`
now := time.Now()
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new project permissions")
log.DebugContext(repo.ctx, "Inserting new publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
@@ -97,7 +97,7 @@ func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permis
sql.Named("updated_at", now.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -109,24 +109,24 @@ func (repo Permissions) Create(project, user uuid.UUID, permissions model.Permis
return nil
}
func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permissions, error) {
func (repo Permissions) GetByID(publication uuid.UUID, user uuid.UUID) (model.Permissions, error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT permissions_value FROM project_permissions
WHERE project_id = :project_id
SELECT permissions_value FROM publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("projcet_id", project.String()),
log := repo.log.With(slog.String("projcet_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Getting by ID")
row := repo.db.QueryRowContext(repo.ctx, q,
sql.Named("project_id", user),
sql.Named("publication_id", user),
sql.Named("user_id", user))
var p model.Permissions
@@ -138,7 +138,7 @@ func (repo Permissions) GetByID(project uuid.UUID, user uuid.UUID) (model.Permis
return p, nil
}
// GetByUserID returns a project_id-to-permissions map containing all projects and permissions that said userID
// GetByUserID returns a publication_id-to-permissions map containing all publications and permissions that said userID
// has relation to.
func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]model.Permissions, err error) {
repo.assert.NotNil(repo.db)
@@ -152,7 +152,7 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
}
q := `
SELECT project_id, permissions_value FROM project_permissions
SELECT publication_id, permissions_value FROM publication_permissions
WHERE user_id = :user_id
`
@@ -176,16 +176,16 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
ps := map[uuid.UUID]model.Permissions{}
for rows.Next() {
var project uuid.UUID
var publication uuid.UUID
var permissions model.Permissions
err := rows.Scan(&project, &permissions)
err := rows.Scan(&publication, &permissions)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan permissions of user id", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps[project] = permissions
ps[publication] = permissions
}
if err := tx.Commit(); err != nil {
@@ -196,7 +196,7 @@ func (repo Permissions) GetByUserID(user uuid.UUID) (permissions map[uuid.UUID]m
return ps, nil
}
func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permissions) error {
func (repo Permissions) Update(publication, user uuid.UUID, permissions model.Permissions) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
@@ -207,20 +207,20 @@ func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permis
}
q := `
UPDATE project_permissions
UPDATE publication_permissions
SET permissions_value = :permissions_value
_permissions_text = :permissions_text
updated_at = :updated_at
WHERE project_uuid = :project_uuid
WHERE publication_uuid = :publication_uuid
AND user_uuid = :user_uuid
`
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("permissions", fmt.Sprintf("%d", permissions)),
slog.String("permissions_text", permissions.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Updating project permissions")
log.DebugContext(repo.ctx, "Updating publication permissions")
now := time.Now()
@@ -228,11 +228,11 @@ func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permis
sql.Named("permissions_value", permissions),
sql.Named("permissions_text", permissions.String()),
sql.Named("updated_at", now.Format(dateFormat)),
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to update project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to update publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -244,7 +244,7 @@ func (repo Permissions) Update(project, user uuid.UUID, permissions model.Permis
return nil
}
func (repo Permissions) Delete(project, user uuid.UUID) error {
func (repo Permissions) Delete(publication, user uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -255,22 +255,22 @@ func (repo Permissions) Delete(project, user uuid.UUID) error {
}
q := `
DELETE FROM project_permissions
WHERE project_id = :project_id
DELETE FROM publication_permissions
WHERE publication_id = :publication_id
AND user_id = :user_id
`
log := repo.log.With(slog.String("project_id", project.String()),
log := repo.log.With(slog.String("publication_id", publication.String()),
slog.String("user_id", user.String()),
slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project permissions")
log.DebugContext(repo.ctx, "Deleting publication permissions")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("project_id", project),
sql.Named("publication_id", publication),
sql.Named("user_id", user),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project permissions", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to delete publication permissions", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}

View File

@@ -14,11 +14,11 @@ import (
"github.com/google/uuid"
)
type Project struct {
type Publication struct {
baseRepostiory
}
func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Project, error) {
func NewPublication(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyssert.Assertions) (*Publication, error) {
b := newBaseRepostiory(ctx, db, log, assert)
tx, err := db.BeginTx(ctx, nil)
@@ -27,7 +27,7 @@ func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyss
}
_, err = tx.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS projects (
CREATE TABLE IF NOT EXISTS publications (
id TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
created_at TEXT NOT NULL,
@@ -38,13 +38,13 @@ func NewProject(ctx context.Context, db *sql.DB, log *slog.Logger, assert tinyss
}
if err := tx.Commit(); err != nil {
return nil, errors.Join(errors.New("unable to create project tables"), err)
return nil, errors.Join(errors.New("unable to create publication tables"), err)
}
return &Project{baseRepostiory: b}, nil
return &Publication{baseRepostiory: b}, nil
}
func (repo Project) Create(p model.Project) error {
func (repo Publication) Create(p model.Publication) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -59,12 +59,12 @@ func (repo Project) Create(p model.Project) error {
}
q := `
INSERT INTO projects (id, title, created_at, updated_at)
INSERT INTO publications (id, title, created_at, updated_at)
VALUES (:id, :title, :created_at, :updated_at)
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Inserting new project")
log.DebugContext(repo.ctx, "Inserting new publication")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("id", p.ID),
@@ -73,7 +73,7 @@ func (repo Project) Create(p model.Project) error {
sql.Named("updated_at", p.DateUpdated.Format(dateFormat)),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -85,20 +85,20 @@ func (repo Project) Create(p model.Project) error {
return nil
}
func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err error) {
func (repo Publication) GetByID(publicationID uuid.UUID) (publication model.Publication, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
q := `
SELECT id, title, created_at, updated_at FROM projects
SELECT id, title, created_at, updated_at FROM publications
WHERE id = :id
`
log := repo.log.With(slog.String("query", q), slog.String("id", projectID.String()))
log.DebugContext(repo.ctx, "Getting project by ID")
log := repo.log.With(slog.String("query", q), slog.String("id", publicationID.String()))
log.DebugContext(repo.ctx, "Getting publication by ID")
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", projectID))
row := repo.db.QueryRowContext(repo.ctx, q, sql.Named("id", publicationID))
var id uuid.UUID
var title string
@@ -106,23 +106,23 @@ func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err err
err = row.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
return model.Project{}, errors.Join(ErrInvalidOutput, err)
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return model.Publication{}, errors.Join(ErrInvalidOutput, err)
}
return model.Project{
return model.Publication{
ID: id,
Title: title,
DateCreated: dateCreated,
@@ -130,7 +130,7 @@ func (repo Project) GetByID(projectID uuid.UUID) (project model.Project, err err
}, nil
}
func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err error) {
func (repo Publication) GetByIDs(ids []uuid.UUID) (publications []model.Publication, err error) {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.log)
@@ -147,16 +147,16 @@ func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err err
}
q := fmt.Sprintf(`
SELECT id, title, created_at, updated_at FROM projects
SELECT id, title, created_at, updated_at FROM publications
WHERE %s
`, strings.Join(c, " OR "))
log := repo.log.With(slog.String("query", q))
log.DebugContext(repo.ctx, "Getting projects by IDs")
log.DebugContext(repo.ctx, "Getting publications by IDs")
rows, err := tx.QueryContext(repo.ctx, q)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to get projects by IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to get publications by IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrExecuteQuery, err)
}
@@ -167,7 +167,7 @@ func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err err
}
}()
ps := []model.Project{}
ps := []model.Publication{}
for rows.Next() {
var id uuid.UUID
@@ -176,23 +176,23 @@ func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err err
err := rows.Scan(&id, &title, &dateCreatedStr, &dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateCreated, err := time.Parse(dateFormat, dateCreatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
dateUpdated, err := time.Parse(dateFormat, dateUpdatedStr)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to scan projects with IDs", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to scan publications with IDs", slog.String("error", err.Error()))
return nil, errors.Join(ErrInvalidOutput, err)
}
ps = append(ps, model.Project{
ps = append(ps, model.Publication{
ID: id,
Title: title,
DateCreated: dateCreated,
@@ -208,7 +208,7 @@ func (repo Project) GetByIDs(ids []uuid.UUID) (projects []model.Project, err err
return ps, nil
}
func (repo Project) Update(p model.Project) error {
func (repo Publication) Update(p model.Publication) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -223,14 +223,14 @@ func (repo Project) Update(p model.Project) error {
}
q := `
UPDATE projects
UPDATE publications
SET title = :title
updated_at = :updated_at
WHERE id = :id
`
log := repo.log.With(slog.String("id", p.ID.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Updating project")
log.DebugContext(repo.ctx, "Updating publication")
_, err = tx.ExecContext(repo.ctx, q,
sql.Named("title", p.Title),
@@ -238,7 +238,7 @@ func (repo Project) Update(p model.Project) error {
sql.Named("id", p.ID),
)
if err != nil {
log.ErrorContext(repo.ctx, "Failed to insert project", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to insert publication", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}
@@ -250,7 +250,7 @@ func (repo Project) Update(p model.Project) error {
return nil
}
func (repo Project) DeleteByID(id uuid.UUID) error {
func (repo Publication) DeleteByID(id uuid.UUID) error {
repo.assert.NotNil(repo.db)
repo.assert.NotNil(repo.ctx)
repo.assert.NotNil(repo.ctx)
@@ -261,15 +261,15 @@ func (repo Project) DeleteByID(id uuid.UUID) error {
}
q := `
DELETE FROM projects WHERE id = :id
DELETE FROM publications WHERE id = :id
`
log := repo.log.With(slog.String("id", id.String()), slog.String("query", q))
log.DebugContext(repo.ctx, "Deleting project")
log.DebugContext(repo.ctx, "Deleting publication")
_, err = tx.ExecContext(repo.ctx, q, sql.Named("id", id))
if err != nil {
log.ErrorContext(repo.ctx, "Failed to delete project", slog.String("error", err.Error()))
log.ErrorContext(repo.ctx, "Failed to delete publication", slog.String("error", err.Error()))
return errors.Join(ErrExecuteQuery, err)
}

View File

@@ -35,6 +35,7 @@ func newBaseRepostiory(ctx context.Context, db *sql.DB, log *slog.Logger, assert
var (
// TODO: Change all ErrDatabaseConn to ErrCloseConn
// TODO: Change error to be agnostic to underlying storage type
ErrDatabaseConn = errors.New("repository: failed to begin transaction/connection with database")
ErrCloseConn = errors.New("repository: failed to close/commit connection")
ErrExecuteQuery = errors.New("repository: failed to execute query")

View File

@@ -14,27 +14,27 @@ import (
"github.com/google/uuid"
)
type projectController struct {
projectSvc *service.Project
type publicationController struct {
publicationSvc *service.Publication
templates templates.ITemplate
assert tinyssert.Assertions
}
func newProjectController(
projectService *service.Project,
func newPublicationController(
publicationService *service.Publication,
templates templates.ITemplate,
assertions tinyssert.Assertions,
) *projectController {
return &projectController{
projectSvc: projectService,
templates: templates,
assert: assertions,
) *publicationController {
return &publicationController{
publicationSvc: publicationService,
templates: templates,
assert: assertions,
}
}
func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request) {
func (ctrl publicationController) dashboard(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
@@ -43,7 +43,7 @@ func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request)
return
}
projects, err := ctrl.projectSvc.GetUserProjects(userID)
publications, err := ctrl.publicationSvc.ListOwnedBy(userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
@@ -52,15 +52,15 @@ func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request)
ps := make([]struct {
ID string
Title string
}, len(projects))
}, len(publications))
for i, project := range projects {
for i, publication := range publications {
ps[i] = struct {
ID string
Title string
}{
ID: base64.URLEncoding.EncodeToString([]byte(project.ID.String())),
Title: project.Title,
ID: base64.URLEncoding.EncodeToString([]byte(publication.ID.String())),
Title: publication.Title,
}
}
@@ -70,24 +70,24 @@ func (ctrl projectController) dashboard(w http.ResponseWriter, r *http.Request)
}
}
func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private projects
func (ctrl publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
// TODO: Handle private publications
shortProjectID := r.PathValue("projectID")
shortPublicationID := r.PathValue("publicationID")
id, err := base64.URLEncoding.DecodeString(shortProjectID)
id, err := base64.URLEncoding.DecodeString(shortPublicationID)
if err != nil {
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded project ID: %s", err.Error())).ServeHTTP(w, r)
problem.NewBadRequest(fmt.Sprintf("Incorrectly encoded publication ID: %s", err.Error())).ServeHTTP(w, r)
return
}
projectID, err := uuid.ParseBytes(id)
publicationID, err := uuid.ParseBytes(id)
if err != nil {
problem.NewBadRequest("Project ID is not a valid UUID").ServeHTTP(w, r)
problem.NewBadRequest("Publication ID is not a valid UUID").ServeHTTP(w, r)
return
}
project, err := ctrl.projectSvc.GetProject(projectID)
publication, err := ctrl.publicationSvc.Get(publicationID)
if errors.Is(err, service.ErrNotFound) {
problem.NewNotFound().ServeHTTP(w, r)
return
@@ -96,8 +96,8 @@ func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request)
return
}
// TODO: Return project template
b, err := json.Marshal(project)
// TODO: Return publication template
b, err := json.Marshal(publication)
w.Header().Add("Content-Type", "application/json")
if _, err := w.Write(b); err != nil {
@@ -106,7 +106,7 @@ func (ctrl projectController) getProject(w http.ResponseWriter, r *http.Request)
}
}
func (ctrl projectController) createProject(w http.ResponseWriter, r *http.Request) {
func (ctrl publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
userCtx := NewUserContext(r.Context())
userID, ok := userCtx.GetUserID()
@@ -121,12 +121,12 @@ func (ctrl projectController) createProject(w http.ResponseWriter, r *http.Reque
return
}
project, err := ctrl.projectSvc.Create(title, userID)
publication, err := ctrl.publicationSvc.Create(title, userID)
if err != nil {
problem.NewInternalServerError(err).ServeHTTP(w, r)
return
}
path := fmt.Sprintf("/p/%s/", base64.URLEncoding.EncodeToString([]byte(project.ID.String())))
path := fmt.Sprintf("/publication/%s/", base64.URLEncoding.EncodeToString([]byte(publication.ID.String())))
http.Redirect(w, r, path, http.StatusSeeOther)
}

View File

@@ -16,9 +16,9 @@ import (
)
type router struct {
userService *service.User
tokenService *service.Token
projectService *service.Project
userService *service.User
tokenService *service.Token
publicationService *service.Publication
templates templates.ITemplate
assets fs.FS
@@ -35,8 +35,8 @@ func New(cfg Config) (http.Handler, error) {
if cfg.TokenService == nil {
return nil, errors.New("token service is nil")
}
if cfg.ProjectService == nil {
return nil, errors.New("project service is nil")
if cfg.PublicationService == nil {
return nil, errors.New("publication service is nil")
}
if cfg.Templates == nil {
return nil, errors.New("templates is nil")
@@ -52,9 +52,9 @@ func New(cfg Config) (http.Handler, error) {
}
r := &router{
userService: cfg.UserService,
tokenService: cfg.TokenService,
projectService: cfg.ProjectService,
userService: cfg.UserService,
tokenService: cfg.TokenService,
publicationService: cfg.PublicationService,
templates: cfg.Templates,
assets: cfg.Assets,
@@ -68,9 +68,9 @@ func New(cfg Config) (http.Handler, error) {
}
type Config struct {
UserService *service.User
TokenService *service.Token
ProjectService *service.Project
UserService *service.User
TokenService *service.Token
PublicationService *service.Publication
Templates templates.ITemplate
Assets fs.FS
@@ -121,7 +121,7 @@ func (router *router) setup() http.Handler {
Templates: router.templates,
Assert: router.assert,
})
projectController := newProjectController(router.projectService, router.templates, router.assert)
publicationController := newPublicationController(router.publicationService, router.templates, router.assert)
r.Handle("GET /assets/{_file...}", http.StripPrefix("/assets/", http.FileServerFS(router.assets)))
@@ -131,7 +131,7 @@ func (router *router) setup() http.Handler {
// TODO: Add a way to the user to bypass this check and see the landing page.
// Probably a query parameter to bypass like "?landing=true"
if _, ok := NewUserContext(r.Context()).GetUserID(); ok {
projectController.dashboard(w, r)
publicationController.dashboard(w, r)
return
}
@@ -146,9 +146,9 @@ func (router *router) setup() http.Handler {
r.HandleFunc("GET /register/{$}", userController.register)
r.HandleFunc("POST /register/{$}", userController.register)
// TODO: Provide/redirect short project-id paths to long paths with the project title as URL /projects/title-of-the-project-<start of uuid>
r.HandleFunc("GET /p/{projectID}/{$}", projectController.getProject)
r.HandleFunc("POST /p/{$}", projectController.createProject)
// TODO: Provide/redirect short publication-id paths to long paths with the publication title as URL /publications/title-of-the-publication-<start of uuid>
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationController.getPublication)
r.HandleFunc("POST /publication/{$}", publicationController.createPublication)
return r
}

View File

@@ -1,124 +0,0 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Project struct {
projectRepo *repository.Project
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewProject(
project *repository.Project,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Project {
return &Project{
projectRepo: project,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Project) Create(title string, ownerUserID ...uuid.UUID) (model.Project, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating project")
defer log.Info("Finished creating project")
id, err := uuid.NewV7()
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Project{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.projectRepo.Create(p)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to create project: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Project{}, err
}
}
return p, nil
}
func (svc Project) SetAuthor(projectID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("project", projectID.String()), slog.String("user", userID.String()))
log.Info("Setting project owner")
defer log.Info("Finished setting project owner")
if _, err := svc.permissionRepo.GetByID(projectID, userID); err == nil {
err := svc.permissionRepo.Update(projectID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update project author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(projectID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set project owner: %w", err)
}
return nil
}
func (svc Project) GetUserProjects(userID uuid.UUID) ([]model.Project, error) {
perms, err := svc.permissionRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
}
ids := []uuid.UUID{}
for project, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, project)
}
}
if len(ids) == 0 {
return []model.Project{}, nil
}
projects, err := svc.projectRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get projects: %w", err)
}
return projects, nil
}
func (svc Project) GetProject(projectID uuid.UUID) (model.Project, error) {
p, err := svc.projectRepo.GetByID(projectID)
if err != nil {
return model.Project{}, fmt.Errorf("service: failed to get project: %w", err)
}
return p, nil
}

124
service/publication.go Normal file
View File

@@ -0,0 +1,124 @@
package service
import (
"fmt"
"log/slog"
"time"
"code.capytal.cc/capytal/comicverse/model"
"code.capytal.cc/capytal/comicverse/repository"
"code.capytal.cc/loreddev/x/tinyssert"
"github.com/google/uuid"
)
type Publication struct {
publicationRepo *repository.Publication
permissionRepo *repository.Permissions
log *slog.Logger
assert tinyssert.Assertions
}
func NewPublication(
publication *repository.Publication,
permissions *repository.Permissions,
logger *slog.Logger,
assertions tinyssert.Assertions,
) *Publication {
return &Publication{
publicationRepo: publication,
permissionRepo: permissions,
log: logger,
assert: assertions,
}
}
func (svc Publication) Get(publicationID uuid.UUID) (model.Publication, error) {
p, err := svc.publicationRepo.GetByID(publicationID)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to get publication: %w", err)
}
return p, nil
}
func (svc Publication) Create(title string, ownerUserID ...uuid.UUID) (model.Publication, error) {
log := svc.log.With(slog.String("title", title))
log.Info("Creating publication")
defer log.Info("Finished creating publication")
id, err := uuid.NewV7()
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to generate id: %w", err)
}
now := time.Now()
p := model.Publication{
ID: id,
Title: title,
DateCreated: now,
DateUpdated: now,
}
err = svc.publicationRepo.Create(p)
if err != nil {
return model.Publication{}, fmt.Errorf("service: failed to create publication: %w", err)
}
if len(ownerUserID) > 0 {
err := svc.SetAuthor(p.ID, ownerUserID[0])
if err != nil {
return model.Publication{}, err
}
}
return p, nil
}
func (svc Publication) SetAuthor(publicationID uuid.UUID, userID uuid.UUID) error {
log := svc.log.With(slog.String("publication", publicationID.String()), slog.String("user", userID.String()))
log.Info("Setting publication owner")
defer log.Info("Finished setting publication owner")
if _, err := svc.permissionRepo.GetByID(publicationID, userID); err == nil {
err := svc.permissionRepo.Update(publicationID, userID, model.PermissionAuthor)
if err != nil {
return fmt.Errorf("service: failed to update publication author: %w", err)
}
}
p := model.PermissionAuthor
err := svc.permissionRepo.Create(publicationID, userID, p)
if err != nil {
return fmt.Errorf("service: failed to set publication owner: %w", err)
}
return nil
}
func (svc Publication) ListOwnedBy(userID uuid.UUID) ([]model.Publication, error) {
perms, err := svc.permissionRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("service: failed to get user permissions: %w", err)
}
ids := []uuid.UUID{}
for publication, permissions := range perms {
if permissions.Has(model.PermissionRead) {
ids = append(ids, publication)
}
}
if len(ids) == 0 {
return []model.Publication{}, nil
}
publications, err := svc.publicationRepo.GetByIDs(ids)
if err != nil {
return nil, fmt.Errorf("service: failed to get publications: %w", err)
}
return publications, nil
}

View File

@@ -4,12 +4,12 @@
{{if and (ne . nil) (ne (len .) 0)}}
<section class="flex h-64 flex-col gap-5">
<div class="flex justify-between">
<h2 class="text-2xl">Projects</h2>
<form action="/p/" method="post">
<h2 class="text-2xl">Publications</h2>
<form action="/publication/" method="post">
<button
class="rounded-full bg-slate-700 p-1 px-3 text-sm text-slate-100"
>
New project
New publication
</button>
</form>
</div>
@@ -20,11 +20,11 @@
<div class="w-38 grid h-full grid-rows-2 bg-slate-500">
<div class="bg-blue-500 p-2">Image</div>
<div class="p-2">
<a href="/p/{{.ID}}/">
<a href="/publication/{{.ID}}/">
<h3>{{.Title}}</h3>
<p class="hidden">{{.ID}}</p>
</a>
<form action="/p/{{.ID}}/" method="post">
<form action="/publication/{{.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete" />
<button
class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100"
@@ -41,16 +41,20 @@
<div
class="fixed flex h-screen w-full items-center justify-center top-0 left-0"
>
<form action="/p/" method="post" class="bg-slate-300 rounded-full">
<form
action="/publication/"
method="post"
class="bg-slate-300 rounded-full"
>
<input
type="text"
name="title"
placeholder="Project title"
placeholder="Publication title"
required
class="pl-5"
/>
<button class="rounded-full bg-slate-700 p-2 px-5 text-slate-100">
New project
New publication
</button>
</form>
</div>

View File

@@ -1,75 +0,0 @@
{{define "project"}}
{{template "layout-page-start" (args "Title" .Title)}}
<div class="fixed w-full h-full bg-green-500 grid grid-cols-4 grid-rows-1">
<nav class="bg-red-500 h-full">
<h1>{{.Title}}</h1>
<p>{{.ID}}</p>
</nav>
<main class="overflow-y-scroll flex justify-center col-span-3 py-20">
<div class="flex flex-col gap-10 h-fit">
{{range $page := .Pages}}
<section id="{{$page.ID}}" class="w-fit">
<!--
INFO: The interaction form could be another page that is shown
when "Add Interaction" is clicked. Said page could be also a partial
than can replace the current image using htmx, so it is
compatible with JavaScript enabled or not.
-->
<div class="flex flex-row">
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/" method="post" class="w-100">
<div class="flex">
{{if (gt (len $page.Interactions) 0)}}
<div class="relative flex">
<div class="absolute z-2 w-full h-full top-0 left-0">
{{range $interactionID, $interaction := $page.Interactions}}
<a class="absolute" href="{{$interaction.URL}}"
style="top:{{$interaction.Y}}%;left:{{$interaction.X}}%;">
<span
class="bg-red-200 opacity-10 block w-10 h-10 transform -translate-x-[50%] -translate-y-[50%]"></span>
</a>
{{end}}
</div>
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
</div>
{{else}}
<img src="/projects/{{$.ID}}/pages/{{$page.ID}}/" class="z-1 relative">
{{end}}
<input type="range" min="0" max="100" name="y" style="writing-mode: vertical-lr;">
</div>
<input type="range" min="0" max="100" name="x" class="w-full">
<input type="url" required name="link" class="bg-slate-300" placeholder="url of interaction">
<button class="rounded-full bg-blue-700 p-1 px-3 text-sm text-slate-100">
Add interaction
</button>
</form>
{{if (gt (len $page.Interactions) 0)}}
<div class="flex flex-col gap-2">
{{range $interactionID, $interaction := $page.Interactions}}
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/interactions/{{$interactionID}}/"
method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
&#x1F5D1;&#xFE0F;{{$interaction.URL}}
</button>
</form>
{{end}}
</div>
{{end}}
</div>
<form action="/projects/{{$.ID}}/pages/{{$page.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete">
<button class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100">
Delete
</button>
</form>
</section>
{{end}}
<form action="/projects/{{.ID}}/pages/" method="post" enctype="multipart/form-data">
<input type="file" name="image" required>
<button>Add new page</button>
</form>
</div>
</main>
</div>
{{template "layout-page-end"}}
{{end}}

View File

@@ -1 +0,0 @@
{{define "projects"}} {{end}}

111
templates/publication.html Normal file
View File

@@ -0,0 +1,111 @@
{{define "publication"}} {{template "layout-page-start" (args "Title" .Title)}}
<div class="fixed w-full h-full bg-green-500 grid grid-cols-4 grid-rows-1">
<nav class="bg-red-500 h-full">
<h1>{{.Title}}</h1>
<p>{{.ID}}</p>
</nav>
<main class="overflow-y-scroll flex justify-center col-span-3 py-20">
<div class="flex flex-col gap-10 h-fit">
{{range $page := .Pages}}
<section id="{{$page.ID}}" class="w-fit">
<!--
INFO: The interaction form could be another page that is shown
when "Add Interaction" is clicked. Said page could be also a partial
than can replace the current image using htmx, so it is
compatible with JavaScript enabled or not.
-->
<div class="flex flex-row">
<form
action="/publications/{{$.ID}}/pages/{{$page.ID}}/interactions/"
method="post"
class="w-100"
>
<div class="flex">
{{if (gt (len $page.Interactions) 0)}}
<div class="relative flex">
<div class="absolute z-2 w-full h-full top-0 left-0">
{{range $interactionID, $interaction := $page.Interactions}}
<a
class="absolute"
href="{{$interaction.URL}}"
style="top:{{$interaction.Y}}%;left:{{$interaction.X}}%;"
>
<span
class="bg-red-200 opacity-10 block w-10 h-10 transform -translate-x-[50%] -translate-y-[50%]"
></span>
</a>
{{end}}
</div>
<img
src="/publications/{{$.ID}}/pages/{{$page.ID}}/"
class="z-1 relative"
/>
</div>
{{else}}
<img
src="/publications/{{$.ID}}/pages/{{$page.ID}}/"
class="z-1 relative"
/>
{{end}}
<input
type="range"
min="0"
max="100"
name="y"
style="writing-mode: vertical-lr"
/>
</div>
<input type="range" min="0" max="100" name="x" class="w-full" />
<input
type="url"
required
name="link"
class="bg-slate-300"
placeholder="url of interaction"
/>
<button
class="rounded-full bg-blue-700 p-1 px-3 text-sm text-slate-100"
>
Add interaction
</button>
</form>
{{if (gt (len $page.Interactions) 0)}}
<div class="flex flex-col gap-2">
{{range $interactionID, $interaction := $page.Interactions}}
<form
action="/publications/{{$.ID}}/pages/{{$page.ID}}/interactions/{{$interactionID}}/"
method="post"
>
<input type="hidden" name="x-method" value="delete" />
<button
class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100"
>
&#x1F5D1;&#xFE0F;{{$interaction.URL}}
</button>
</form>
{{end}}
</div>
{{end}}
</div>
<form action="/publications/{{$.ID}}/pages/{{$page.ID}}/" method="post">
<input type="hidden" name="x-method" value="delete" />
<button
class="rounded-full bg-red-700 p-1 px-3 text-sm text-slate-100"
>
Delete
</button>
</form>
</section>
{{end}}
<form
action="/publications/{{.ID}}/pages/"
method="post"
enctype="multipart/form-data"
>
<input type="file" name="image" required />
<button>Add new page</button>
</form>
</div>
</main>
</div>
{{template "layout-page-end"}} {{end}}

View File

@@ -0,0 +1 @@
{{define "publications"}} {{end}}