Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,31 @@
# 馃殌 Serving gopl.dev

# templ live watch & reload
`go tool templ generate --watch --proxy="http://localhost:8080" --cmd="go run ./cmd/server/main.go"`
### Contributing

# tailwind watch
`tailwindcss -i ./frontend/assets/input.css -o ./frontend/assets/output.css --watch`
We don't have a formal set of rules for contributions yet; everyone is welcome! We appreciate everything from critiques and suggestions to bug fixes and new features.

# linting
https://golangci-lint.run/docs/welcome/install/local/
### Setup Your Own Instance

`golangci-lint run`
See [SETUP.md](SETUP.md) for detailed instructions on how to set up your own instance.

### Internal Tools

# openapi & swagger
https://github.com/swaggo/swag
`swag fmt --dir server/handler`
`swag init --parseDependency --parseDepth 1 --dir server/handler -g handler.go -o server/docs`
* **Reset Dev Environment**
```bash
go run ./cmd/cli/main.go rde
```
Resets the development environment by recreating the database, applying migrations, and creating a default user. This is useful during active development if you need a clean state.

# internal devtools
`go run ./cmd/cli/main.go rde`
Reset dev environment (recreate DB, apply migrations & create default user). Useful during active development when you messed with the DB or need a clean state.

`go run ./cmd/cli/main.go sd`
Will seed data to the database. By default, it seeds all available data. You can specify an entity and a count, for example:
`go run ./cmd/cli/main.go sd users 1000`.
Run `go run ./cmd/cli/main.go ? sd` to see available options and a detailed description.
* **Database Seeding**
```bash
go run ./cmd/cli/main.go sd
```
Seeds data into the database. By default, it seeds all available data. You can specify an entity and a count:
`go run ./cmd/cli/main.go sd users 1000`

Run `go run ./cmd/cli/main.go ? sd` to see all available options and detailed descriptions.

---

License [MIT](LICENSE)
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ By default, it seeds all available entities. You can specify a specific entity a
* **Help:** `go run ./cmd/cli/main.go ? sd` for detailed options.

## Environment Reset
If you need a clean slate, run:
If you need a clean state, run:
```bash
go run ./cmd/cli/main.go rde
```
Expand Down
15 changes: 6 additions & 9 deletions WIP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,17 @@

for simplicity, I keep todo list and progress here for now

**Before release**:
- [ ] Review "TODO!"s
- [x] Homepage
- [ ] Licence
- [x] RELEASE
---

Tweaks & fixes:
- [ ] Add description to upload book cover input
- [X] Add description and limits to upload book cover input
- [ ] When book cover upload ends with error, another cannot be added


TODO:
- [ ] Resend verification link (Sometimes email is lost in somewhere between the woods (Not mailman to blame))
- [ ] Create new topic when creating/editing entity
- [ ] Topics
-[ ] Manage topics (create, edit, delete)
-[ ] Create new topic when creating/editing entity
- [ ] Entity highlight (Show something exciting: New book just released, latest book, random book.)
- [ ] Pages
- [ ] Add meta to page (Who created, last edit by who and list of activities on page (api call))
- [ ] Content chips
Expand All @@ -29,6 +25,7 @@ TODO:
- [ ] Preview for markdown
- [ ] Convert all TODO's into tasks/issues
- [ ] Create a CLI command to set up a new dev environment
- [ ] Showcase
- [ ] Jobs
- [ ] Events
- [ ] Software
Expand Down
8 changes: 4 additions & 4 deletions app/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,22 +102,22 @@ type ConfigT struct {
Admins []string `yaml:"admins"`
}

// IsDevEnv ...
// IsDevEnv returns true if the application environment is set to dev.
func (c *ConfigT) IsDevEnv() bool {
return c.App.Env == DevEnv
}

// IsTestEnv ...
// IsTestEnv returns true if the application environment is set to test.
func (c *ConfigT) IsTestEnv() bool {
return c.App.Env == TestEnv
}

// IsProductionEnv ...
// IsProductionEnv returns true if the application environment is set to production.
func (c *ConfigT) IsProductionEnv() bool {
return c.App.Env == ProductionEnv
}

// TracingDisabled ...
// TracingDisabled returns true if the tracing configuration is not enabled.
func (c *ConfigT) TracingDisabled() bool {
return !c.Tracing.Enabled
}
Expand Down
2 changes: 1 addition & 1 deletion app/database.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ var (
ErrMultipleSameVersion = errors.New("multiple migrations of same version found")
)

// DB ...
// DB wraps the pgxpool.Pool.
type DB struct {
*pgxpool.Pool
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN cleaned_at TIMESTAMPTZ;
22 changes: 20 additions & 2 deletions app/ds/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@ package ds

import (
"context"
"encoding/json"
"time"
)

// DeletedUsername is the placeholder username for soft-deleted users.
const DeletedUsername = "deleted"

const (
userCtxKey ctxKey = "user"

// CleanupDeletedUserAfter ...
CleanupDeletedUserAfter = 30 * 24 * time.Hour
)

// User ...
// User represents a user account in the system.
type User struct {
ID ID `json:"id"`
Username string `json:"username"`
Expand All @@ -22,13 +26,26 @@ type User struct {
CreatedAt time.Time `json:"-"`
UpdatedAt *time.Time `json:"-"`
DeletedAt *time.Time `json:"-"`
CleanedAt *time.Time `json:"-"`

// IsAdmin is true if the user ID is listed in "admins" key in config file.
// This field is set by the auth middleware.
// Until proper RBAC/ACL is implemented, we trust authority generously granted by the devs themselves.
IsAdmin bool `json:"-"`
}

// MarshalJSON implements custom JSON serialization for User.
func (u *User) MarshalJSON() ([]byte, error) {
type Alias User
a := Alias(*u)

if u.Deleted() {
a.Username = DeletedUsername
}

return json.Marshal(&a)
}

// UsersFilter is used to filter and paginate user queries.
type UsersFilter struct {
Page int
Expand All @@ -39,9 +56,10 @@ type UsersFilter struct {
Deleted bool
OrderBy string
OrderDirection string
NotCleaned bool
}

// Deleted ...
// Deleted reports whether the user has been soft-deleted.
func (u *User) Deleted() bool {
return u.DeletedAt != nil
}
Expand Down
9 changes: 9 additions & 0 deletions app/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const (

// CodeInternal corresponds to HTTP 500 Internal Server Error.
CodeInternal = http.StatusInternalServerError

// CodeTooManyRequests corresponds to HTTP 429 Too Many Requests Error.
CodeTooManyRequests = http.StatusTooManyRequests
)

// Error is a custom application error type that includes an HTTP status code
Expand Down Expand Up @@ -147,3 +150,9 @@ func ErrUnauthorized() error {
func ErrForbidden(message string) error {
return NewError(CodeForbidden, message)
}

// ErrTooManyRequests is a convenience function to create a new Error with
// the CodeTooManyRequests (HTTP 429) status.
func ErrTooManyRequests(message string, params ...any) error {
return NewError(CodeTooManyRequests, message, params...)
}
29 changes: 26 additions & 3 deletions app/repo/email_confirmation_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
)

var (
// ErrEmailConfirmationFound is a sentinel error returned when ds.EmailConfirmation not found.
ErrEmailConfirmationFound = app.ErrNotFound("email confirmation not found")
// ErrEmailConfirmationNotFound is a sentinel error returned when ds.EmailConfirmation not found.
ErrEmailConfirmationNotFound = app.ErrNotFound("email confirmation not found")
)

// GetEmailConfirmationByCode retrieves an email confirmation record from the database
Expand All @@ -28,7 +28,7 @@ func (r *Repo) GetEmailConfirmationByCode(ctx context.Context, code string) (ec
)
if noRows(err) {
ec = nil
err = ErrEmailConfirmationFound
err = ErrEmailConfirmationNotFound
}

return
Expand Down Expand Up @@ -72,3 +72,26 @@ func (r *Repo) DeleteEmailConfirmationByUser(ctx context.Context, userID ds.ID)

return r.exec(ctx, "DELETE FROM email_confirmations WHERE user_id = $1", userID)
}

// GetLatestEmailConfirmationByUserID returns the most recent email confirmation record for the given user.
func (r *Repo) GetLatestEmailConfirmationByUserID(ctx context.Context, userID ds.ID) (*ds.EmailConfirmation, error) {
ctx, span := r.tracer.Start(ctx, "GetLatestEmailConfirmationByUserID")
defer span.End()

ec := new(ds.EmailConfirmation)
err := pgxscan.Get(ctx, r.db, ec,
`SELECT * FROM email_confirmations
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT 1`,
userID,
)
if noRows(err) {
return nil, ErrEmailConfirmationNotFound
}
if err != nil {
return nil, err
}

return ec, nil
}
8 changes: 8 additions & 0 deletions app/repo/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,14 @@ func (b *filterBuilder) where(column string, val any) *filterBuilder {
return b
}

func (b *filterBuilder) whereIf(cond bool, column string, val any) *filterBuilder {
if cond {
b.where(column, val)
}

return b
}

func (b *filterBuilder) whereRaw(steak string, seasoning ...any) *filterBuilder {
if steak == "" {
return b
Expand Down
31 changes: 21 additions & 10 deletions app/repo/user_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package repo
import (
"context"
"errors"
"fmt"
"time"

"github.com/georgysavva/scany/v2/pgxscan"
Expand Down Expand Up @@ -131,16 +132,7 @@ func (r *Repo) DeleteUser(ctx context.Context, userID ds.ID) (err error) {
return r.delete(ctx, "users", userID)
}

// HardDeleteUser permanently deletes a user.
// This is not a logout. See you at the Afterlife, V.
func (r *Repo) HardDeleteUser(ctx context.Context, userID ds.ID) (err error) {
_, span := r.tracer.Start(ctx, "HardDeleteUser")
defer span.End()

return r.hardDelete(ctx, "users", userID)
}

// FilterUsers ...
// FilterUsers retrieves a paginated, filtered list of users from the database.
func (r *Repo) FilterUsers(ctx context.Context, f ds.UsersFilter) (users []ds.User, count int, err error) {
_, span := r.tracer.Start(ctx, "FilterUsers")
defer span.End()
Expand All @@ -150,9 +142,28 @@ func (r *Repo) FilterUsers(ctx context.Context, f ds.UsersFilter) (users []ds.Us
createdAt(f.CreatedAt).
deletedAt(f.DeletedAt).
deleted(f.Deleted).
whereIf(f.NotCleaned, "cleaned_at IS NULL", nil).
order(f.OrderBy, f.OrderDirection).
withCount(f.WithCount).
scan(ctx, &users)

return
}

// UpdateUser updates user fields in the database.
func (r *Repo) UpdateUser(ctx context.Context, u *ds.User) error {
_, span := r.tracer.Start(ctx, "UpdateUser")
defer span.End()

err := r.update(ctx, u.ID, "users", data{
"email": u.Email,
"username": u.Username,
"password": u.Password,
"cleaned_at": u.CleanedAt,
})
if err != nil {
return fmt.Errorf("update user: %w", err)
}

return nil
}
Loading
Loading