feat: initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
package auth
|
||||
|
||||
import "github.com/golang-jwt/jwt/v5"
|
||||
|
||||
type Authenticator interface {
|
||||
GenerateToken(jwt.Claims) (string, error)
|
||||
ValidateToken(string) (*jwt.Token, error)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type JWTAuthenticator struct {
|
||||
secret string
|
||||
aud string
|
||||
iss string
|
||||
}
|
||||
|
||||
func NewJWTAuthenticator(secret, aud, iss string) *JWTAuthenticator {
|
||||
return &JWTAuthenticator{
|
||||
secret: secret,
|
||||
aud: aud,
|
||||
iss: iss,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
tokenString, err := token.SignedString([]byte(j.secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func (j *JWTAuthenticator) ValidateToken(tokenString string) (*jwt.Token, error) {
|
||||
return jwt.Parse(tokenString, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
|
||||
return []byte(j.secret), nil
|
||||
},
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithAudience(j.aud),
|
||||
jwt.WithIssuer(j.iss),
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Name}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TestAuthenticator struct {
|
||||
}
|
||||
|
||||
const testToken = "test-token"
|
||||
|
||||
var testClaims = jwt.MapClaims{
|
||||
"aud": "test-audience",
|
||||
"iss": "test-issuer",
|
||||
"sub": 1,
|
||||
"exp": time.Now().Add(time.Hour * 24).Unix(),
|
||||
}
|
||||
|
||||
func (a *TestAuthenticator) GenerateToken(claims jwt.Claims) (string, error) {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, testClaims)
|
||||
|
||||
return token.SignedString([]byte(testToken))
|
||||
}
|
||||
|
||||
func (a *TestAuthenticator) ValidateToken(token string) (*jwt.Token, error) {
|
||||
return jwt.Parse(token, func(token *jwt.Token) (any, error) {
|
||||
return []byte(testToken), nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func New(addr string, maxOpenConns, maxIdleConns int, maxIdleTime time.Duration) (*sql.DB, error) {
|
||||
db, err := sql.Open("postgres", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxIdleTime(maxIdleTime)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/store"
|
||||
)
|
||||
|
||||
var first_names = []string{
|
||||
"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy",
|
||||
"Mallory", "Nathan", "Olivia", "Peggy", "Quinn", "Rachel", "Sam", "Trent", "Ursula", "Victor",
|
||||
"Wendy", "Xander", "Yvonne", "Zach", "Amy", "Brian", "Carl", "Diana", "Edward", "Fiona",
|
||||
"George", "Hannah", "Ian", "Jessica", "Kevin", "Lisa", "Michael", "Nina", "Oscar", "Paula",
|
||||
"Quentin", "Rebecca", "Steve", "Tina", "Uma", "Vincent", "Willow", "Xenia", "Yuri", "Zoe",
|
||||
}
|
||||
|
||||
var last_names = []string{
|
||||
"Smith", "Johnson", "Williams", "Jones", "Brown", "Davis", "Miller", "Wilson", "Moore", "Taylor",
|
||||
"Anderson", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Garcia", "Martinez", "Robinson",
|
||||
"Clark", "Rodriguez", "Lewis", "Lee", "Walker", "Hall", "Allen", "Young", "Hernandez", "King",
|
||||
"Wright", "Lopez", "Hill", "Scott", "Green", "Adams", "Baker", "Gonzalez", "Nelson", "Carter",
|
||||
"Mitchell", "Perez", "Roberts", "Turner", "Phillips", "Campbell", "Parker", "Evans", "Edwards", "Collins",
|
||||
}
|
||||
|
||||
var usernames = []string{
|
||||
"alice", "bob", "charlie", "dave", "eve", "frank", "grace", "heidi", "ivan", "judy",
|
||||
"mallory", "nathan", "olivia", "peggy", "quinn", "rachel", "sam", "trent", "ursula", "victor",
|
||||
"wendy", "xander", "yvonne", "zach", "amy", "brian", "carl", "diana", "edward", "fiona",
|
||||
"george", "hannah", "ian", "jessica", "kevin", "lisa", "michael", "nina", "oscar", "paula",
|
||||
"quentin", "rebecca", "steve", "tina", "uma", "vincent", "willow", "xenia", "yuri", "zoe",
|
||||
}
|
||||
|
||||
var titles = []string{
|
||||
"How to Learn Go in 10 Days",
|
||||
"Understanding Concurrency in Go",
|
||||
"Building REST APIs with Go",
|
||||
"Mastering Go: Tips and Tricks",
|
||||
"Go vs. Python: A Comparison",
|
||||
"Error Handling in Go",
|
||||
"Go Routines and Channels Explained",
|
||||
"Building a Web Server in Go",
|
||||
"Go for Beginners: A Comprehensive Guide",
|
||||
"Advanced Go Programming Techniques",
|
||||
"Testing in Go: Best Practices",
|
||||
"Go Modules: Managing Dependencies",
|
||||
"Introduction to Go Interfaces",
|
||||
"Go Data Structures and Algorithms",
|
||||
"Building CLI Applications with Go",
|
||||
"Go Memory Management",
|
||||
"Go Design Patterns",
|
||||
"Deploying Go Applications",
|
||||
"Go and Microservices",
|
||||
"Profiling and Optimizing Go Code",
|
||||
}
|
||||
|
||||
var contents = []string{
|
||||
"Learn the basics of Go programming in just 10 days with this comprehensive guide.",
|
||||
"Understand the intricacies of concurrency in Go and how to effectively manage goroutines.",
|
||||
"Step-by-step guide to building RESTful APIs using the Go programming language.",
|
||||
"Discover tips and tricks to master Go programming and write efficient code.",
|
||||
"Compare the features and performance of Go and Python in various scenarios.",
|
||||
"Learn how to handle errors gracefully in Go and write robust applications.",
|
||||
"An in-depth explanation of goroutines and channels in Go for concurrent programming.",
|
||||
"Build a simple yet powerful web server using Go and the net/http package.",
|
||||
"A comprehensive guide for beginners to learn Go programming from scratch.",
|
||||
"Explore advanced programming techniques in Go to write high-performance applications.",
|
||||
"Best practices for writing and running tests in Go to ensure code quality.",
|
||||
"Manage dependencies in your Go projects using Go Modules effectively.",
|
||||
"An introduction to interfaces in Go and how to use them for polymorphism.",
|
||||
"Learn about various data structures and algorithms implemented in Go.",
|
||||
"Build command-line applications in Go with ease using the flag package.",
|
||||
"Understand Go's memory management and how to optimize memory usage.",
|
||||
"Explore common design patterns in Go to write maintainable and scalable code.",
|
||||
"Learn how to deploy Go applications to various environments.",
|
||||
"Discover how to build microservices architecture using Go.",
|
||||
"Techniques for profiling and optimizing the performance of Go code.",
|
||||
}
|
||||
|
||||
var tags = []string{
|
||||
"golang", "programming", "tutorial", "webdev", "backend", "frontend", "api", "concurrency", "microservices", "testing",
|
||||
"designpatterns", "cli", "memory", "optimization", "deployment", "databases", "security", "networking", "algorithms", "datastructures",
|
||||
}
|
||||
|
||||
var comments = []string{
|
||||
"Great post! Thanks for sharing.",
|
||||
"Very informative. I learned a lot.",
|
||||
"I disagree with some points, but overall a good read.",
|
||||
"Can you provide more details on this topic?",
|
||||
"Excellent writing! Keep it up.",
|
||||
"This helped me understand Go better. Thanks!",
|
||||
"Interesting perspective. I never thought about it that way.",
|
||||
"Could you share the source code?",
|
||||
"Looking forward to your next post.",
|
||||
"Thanks for the insights. Very helpful.",
|
||||
"Well explained. I appreciate the examples.",
|
||||
"I have a question about concurrency in Go.",
|
||||
"Can you recommend more resources on this topic?",
|
||||
"Great tips! I'll definitely try them out.",
|
||||
"This post is a bit confusing. Can you clarify?",
|
||||
"Thanks for the tutorial. It was easy to follow.",
|
||||
"I found a typo in the code snippet.",
|
||||
"Can you write about error handling in Go?",
|
||||
"Your posts are always so detailed and helpful.",
|
||||
"I tried this and it worked perfectly. Thanks!",
|
||||
}
|
||||
|
||||
func Seed(store store.Storage, db *sql.DB) {
|
||||
ctx := context.Background()
|
||||
|
||||
users := generateUsers(100)
|
||||
tx, _ := db.BeginTx(ctx, nil)
|
||||
|
||||
for _, u := range users {
|
||||
if err := store.Users.Create(ctx, tx, u); err != nil {
|
||||
tx.Rollback()
|
||||
log.Println("failed to create user:", err)
|
||||
}
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
posts := generatePosts(200, users)
|
||||
for _, p := range posts {
|
||||
if err := store.Posts.Create(ctx, p); err != nil {
|
||||
log.Println("failed to create post:", err)
|
||||
}
|
||||
}
|
||||
|
||||
comments := generateComments(500, users, posts)
|
||||
for _, c := range comments {
|
||||
if err := store.Comments.Create(ctx, c); err != nil {
|
||||
log.Println("failed to create comment:", err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("seeded database with sample data")
|
||||
}
|
||||
|
||||
func generateUsers(n int) []*store.User {
|
||||
users := make([]*store.User, n)
|
||||
for i := 0; i < n; i++ {
|
||||
users[i] = &store.User{
|
||||
FirstName: first_names[i%len(first_names)],
|
||||
LastName: last_names[i%len(last_names)],
|
||||
Username: usernames[i%len(usernames)] + fmt.Sprintf("%d", i),
|
||||
Email: usernames[i%len(usernames)] + fmt.Sprintf("%d", i) + "@example.com",
|
||||
Role: store.Role{
|
||||
Name: "user",
|
||||
},
|
||||
}
|
||||
}
|
||||
return users
|
||||
}
|
||||
|
||||
func generatePosts(n int, users []*store.User) []*store.Post {
|
||||
|
||||
posts := make([]*store.Post, n)
|
||||
for i := 0; i < n; i++ {
|
||||
user := users[rand.Intn(len(users))]
|
||||
posts[i] = &store.Post{
|
||||
UserID: user.ID,
|
||||
Title: titles[rand.Intn(len(titles))],
|
||||
Content: contents[rand.Intn(len(contents))],
|
||||
Tags: []string{tags[rand.Intn(len(tags))], tags[rand.Intn(len(tags))]},
|
||||
}
|
||||
}
|
||||
return posts
|
||||
}
|
||||
|
||||
func generateComments(n int, users []*store.User, posts []*store.Post) []*store.Comment {
|
||||
post_comments := make([]*store.Comment, n)
|
||||
for i := 0; i < n; i++ {
|
||||
user := users[rand.Intn(len(users))]
|
||||
post := posts[rand.Intn(len(posts))]
|
||||
post_comments[i] = &store.Comment{
|
||||
PostID: post.ID,
|
||||
UserID: user.ID,
|
||||
Content: comments[rand.Intn(len(comments))],
|
||||
}
|
||||
}
|
||||
return post_comments
|
||||
}
|
||||
Vendored
+64
@@ -0,0 +1,64 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetString(key, fallback string) string {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
func GetInt(key string, fallback int) int {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
valAsInt, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return valAsInt
|
||||
}
|
||||
|
||||
func GetDuration(key string, fallback string) time.Duration {
|
||||
parsedFallback, _ := time.ParseDuration(fallback)
|
||||
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return parsedFallback
|
||||
}
|
||||
|
||||
parsedVal, err := time.ParseDuration(val)
|
||||
if err != nil {
|
||||
return parsedFallback
|
||||
}
|
||||
|
||||
return parsedVal
|
||||
}
|
||||
|
||||
func GetBool(key string, fallback bool) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
valAsBool, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return valAsBool
|
||||
}
|
||||
|
||||
func GetList(key string, fallback []string) []string {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
return strings.Split(val, ",")
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package mailer
|
||||
|
||||
import "embed"
|
||||
|
||||
const (
|
||||
FromName = "LK API"
|
||||
maxRetries = 3
|
||||
UserWelcomeTemplate = "user_activation.tmpl"
|
||||
)
|
||||
|
||||
//go:embed "templates"
|
||||
var FS embed.FS
|
||||
|
||||
type Client interface {
|
||||
Send(templateFile, username, email string, data any, isDevEnv bool) (int, error)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package mailer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/sendgrid/sendgrid-go"
|
||||
"github.com/sendgrid/sendgrid-go/helpers/mail"
|
||||
)
|
||||
|
||||
type SendGridMailer struct {
|
||||
fromEmail string
|
||||
apiKey string
|
||||
client *sendgrid.Client
|
||||
}
|
||||
|
||||
func NewSendGrid(apiKey, fromEmail string) *SendGridMailer {
|
||||
client := sendgrid.NewSendClient(apiKey)
|
||||
return &SendGridMailer{
|
||||
fromEmail: fromEmail,
|
||||
apiKey: apiKey,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SendGridMailer) Send(templateFile, username, email string, data any, isDevEnv bool) (int, error) {
|
||||
from := mail.NewEmail(FromName, s.fromEmail)
|
||||
to := mail.NewEmail(username, email)
|
||||
|
||||
tmpl, err := template.ParseFS(FS, "templates/"+templateFile)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
// Template Parsing and sending
|
||||
subject := new(bytes.Buffer)
|
||||
if err := tmpl.ExecuteTemplate(subject, "subject", data); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
if err := tmpl.ExecuteTemplate(body, "body", data); err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
message := mail.NewSingleEmail(from, subject.String(), to, "", body.String())
|
||||
|
||||
message.SetMailSettings(&mail.MailSettings{
|
||||
SandboxMode: &mail.Setting{
|
||||
Enable: &isDevEnv,
|
||||
},
|
||||
})
|
||||
|
||||
var retryErr error
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
resp, retryErr := s.client.Send(message)
|
||||
if retryErr != nil {
|
||||
//* Exponential backoff
|
||||
time.Sleep(time.Duration(i+1) * time.Second)
|
||||
continue
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
return -1, fmt.Errorf("failed to send the email after %d attempts, error: %v", maxRetries, retryErr)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{{define "subject"}} Finish Registration with {{.ServiceName}} {{end}}
|
||||
|
||||
{{define "body"}}
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
</head>
|
||||
<body> <p>Hi {{.Username}},</p>
|
||||
<p>Thanks for signing up for {{.ServiceName}}. We're excited to have you on board!</p></p>
|
||||
<p>Before you can start using {{.ServiceName}}, you need to confirm your email address. Click the link below to confirm your email address:</p>
|
||||
<p><a href="{{.ActivationURL}}">{{.ActivationURL}}</a></p>
|
||||
<p>If you want to activate your account manually copy and paste the code from the link above</p>
|
||||
<p>If you didn't sign up for {{.ServiceName}}, you can safely ignore this email.</p>
|
||||
|
||||
<p>Thanks,</p>
|
||||
<p>The {{.ServiceName}} Team</p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
package ratelimiter
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FixedWindowLimiter struct {
|
||||
sync.RWMutex
|
||||
clients map[string]int
|
||||
limit int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
func NewFixedWindowLimiter(limit int, window time.Duration) *FixedWindowLimiter {
|
||||
return &FixedWindowLimiter{
|
||||
clients: make(map[string]int),
|
||||
limit: limit,
|
||||
window: window,
|
||||
}
|
||||
}
|
||||
|
||||
func (rl *FixedWindowLimiter) Allow(ip string) (bool, time.Duration) {
|
||||
rl.RLock()
|
||||
count, exists := rl.clients[ip]
|
||||
rl.RUnlock()
|
||||
|
||||
log.Println(count, exists)
|
||||
|
||||
if !exists || count < rl.limit {
|
||||
rl.Lock()
|
||||
if !exists {
|
||||
go rl.resetCount(ip)
|
||||
}
|
||||
|
||||
rl.clients[ip]++
|
||||
rl.Unlock()
|
||||
return true, 0
|
||||
}
|
||||
|
||||
return false, rl.window
|
||||
}
|
||||
|
||||
func (rl *FixedWindowLimiter) resetCount(ip string) {
|
||||
time.Sleep(rl.window)
|
||||
|
||||
rl.Lock()
|
||||
delete(rl.clients, ip)
|
||||
rl.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package ratelimiter
|
||||
|
||||
import "time"
|
||||
|
||||
//! Use Redis for rate limiting when in multiple instances
|
||||
|
||||
type Limiter interface {
|
||||
Allow(ip string) (bool, time.Duration)
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
RequestsPerTimeFrame int
|
||||
TimeFrame time.Duration
|
||||
Enabled bool
|
||||
}
|
||||
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/store"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func NewMockCache() Storage {
|
||||
return Storage{
|
||||
Users: &MockUserStore{},
|
||||
}
|
||||
}
|
||||
|
||||
type MockUserStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Get(ctx context.Context, id int64) (*store.User, error) {
|
||||
args := m.Called(id)
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Set(ctx context.Context, user *store.User) error {
|
||||
args := m.Called(user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
|
||||
args := m.Called(id)
|
||||
return args.Error(0)
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
package cache
|
||||
|
||||
import "github.com/go-redis/redis/v8"
|
||||
|
||||
func NewRedisClient(addr, pw string, db int) *redis.Client {
|
||||
return redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: pw,
|
||||
DB: db,
|
||||
})
|
||||
}
|
||||
Vendored
+22
@@ -0,0 +1,22 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/store"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
Users interface {
|
||||
Get(context.Context, int64) (*store.User, error)
|
||||
Set(context.Context, *store.User) error
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
}
|
||||
|
||||
func NewRedisStorage(rdb *redis.Client) Storage {
|
||||
return Storage{
|
||||
Users: &UserStore{rdb: rdb},
|
||||
}
|
||||
}
|
||||
Vendored
+60
@@ -0,0 +1,60 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/FernandoVideira/LK_API_Temp/internal/store"
|
||||
"github.com/go-redis/redis/v8"
|
||||
)
|
||||
|
||||
type UserStore struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
const UserExpTime = time.Hour * 24
|
||||
|
||||
func (s *UserStore) Get(ctx context.Context, id int64) (*store.User, error) {
|
||||
cacheKey := fmt.Sprintf("user-%d", id)
|
||||
|
||||
data, err := s.rdb.Get(ctx, cacheKey).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var user store.User
|
||||
if data != "" {
|
||||
err := json.Unmarshal([]byte(data), &user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) Set(ctx context.Context, user *store.User) error {
|
||||
|
||||
if user.ID == 0 {
|
||||
return fmt.Errorf("user ID is required")
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("user-%d", user.ID)
|
||||
|
||||
json, err := json.Marshal(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.rdb.SetEX(ctx, cacheKey, json, UserExpTime).Err()
|
||||
}
|
||||
|
||||
func (s *UserStore) Delete(ctx context.Context, id int64) error {
|
||||
cacheKey := fmt.Sprintf("user-%d", id)
|
||||
|
||||
return s.rdb.Del(ctx, cacheKey).Err()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Comment struct {
|
||||
ID int64 `json:"id"`
|
||||
PostID int64 `json:"post_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type CommentStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *CommentStore) Create(ctx context.Context, c *Comment) error {
|
||||
query := `INSERT INTO comments (post_id, user_id, content) VALUES ($1, $2, $3) RETURNING id, created_at, updated_at;`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, c.PostID, c.UserID, c.Content).Scan(&c.ID, &c.CreatedAt, &c.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *CommentStore) GetByPostID(ctx context.Context, postID int64) ([]Comment, error) {
|
||||
query := `SELECT c.id, c.post_id, c.user_id, c.content, c.created_at, users.username, users.id FROM comments c
|
||||
JOIN users on c.user_id = users.id
|
||||
WHERE c.post_id = $1
|
||||
ORDER BY c.created_at DESC;
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, postID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
comments := []Comment{}
|
||||
for rows.Next() {
|
||||
var comment Comment
|
||||
comment.User = User{}
|
||||
err := rows.Scan(
|
||||
&comment.ID,
|
||||
&comment.PostID,
|
||||
&comment.UserID,
|
||||
&comment.Content,
|
||||
&comment.CreatedAt,
|
||||
&comment.User.Username,
|
||||
&comment.User.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
comments = append(comments, comment)
|
||||
}
|
||||
|
||||
return comments, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Follower struct {
|
||||
ID int64 `json:"id"`
|
||||
FolloweeID int64 `json:"followee_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type FollowerStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *FollowerStore) Follow(ctx context.Context, followeeID, followerID int64) error {
|
||||
query := `INSERT INTO followers (user_id, follower_id) VALUES ($1, $2)`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
|
||||
if err != nil {
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *FollowerStore) Unfollow(ctx context.Context, followeeID, followerID int64) error {
|
||||
query := `DELETE FROM followers WHERE user_id = $1 AND follower_id = $2`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.db.ExecContext(ctx, query, followerID, followeeID)
|
||||
if pqErr, ok := err.(*pq.Error); ok && pqErr.Code == "23505" {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func NewMockStorage() Storage {
|
||||
return Storage{
|
||||
Users: &MockUserStore{},
|
||||
}
|
||||
}
|
||||
|
||||
type MockUserStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
|
||||
args := m.Called(tx, user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Activate(ctx context.Context, token string) error {
|
||||
args := m.Called(token)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
args := m.Called(id)
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
args := m.Called(email)
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Update(ctx context.Context, user *User) error {
|
||||
args := m.Called(user)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) CreateAndInvite(ctx context.Context, user *User, token string, expiresAt time.Duration) error {
|
||||
args := m.Called(user, token, expiresAt)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) Delete(ctx context.Context, id int64) error {
|
||||
args := m.Called(id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockUserStore) DeleteUser(ctx context.Context, id int64) error {
|
||||
args := m.Called(id)
|
||||
return args.Error(0)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PaginatedFeedQuery struct {
|
||||
Limit int `json:"limit" validate:"gte=1,lte=20"`
|
||||
Offset int `json:"offset" validate:"gte=0"`
|
||||
Sort string `json:"sort" validate:"oneof=asc desc"`
|
||||
Tags []string `json:"tags" validate:"max=5"`
|
||||
Search string `json:"search" validate:"max=100"`
|
||||
Since string `json:"since"`
|
||||
Until string `json:"until"`
|
||||
}
|
||||
|
||||
func (fq PaginatedFeedQuery) Parse(r *http.Request) (PaginatedFeedQuery, error) {
|
||||
qs := r.URL.Query()
|
||||
|
||||
limit := qs.Get("limit")
|
||||
if limit != "" {
|
||||
l, err := strconv.Atoi(limit)
|
||||
if err != nil {
|
||||
return fq, err
|
||||
}
|
||||
fq.Limit = l
|
||||
}
|
||||
|
||||
offset := qs.Get("offset")
|
||||
if offset != "" {
|
||||
o, err := strconv.Atoi(offset)
|
||||
if err != nil {
|
||||
return PaginatedFeedQuery{}, err
|
||||
}
|
||||
fq.Offset = o
|
||||
}
|
||||
|
||||
sort := qs.Get("sort")
|
||||
if sort != "" {
|
||||
fq.Sort = sort
|
||||
}
|
||||
|
||||
tags := qs.Get("tags")
|
||||
if tags != "" {
|
||||
fq.Tags = strings.Split(tags, ",")
|
||||
}
|
||||
|
||||
search := qs.Get("search")
|
||||
if search != "" {
|
||||
fq.Search = search
|
||||
}
|
||||
|
||||
since := qs.Get("since")
|
||||
if since != "" {
|
||||
fq.Since = parseTime(since)
|
||||
}
|
||||
|
||||
until := qs.Get("until")
|
||||
if until != "" {
|
||||
fq.Until = parseTime(until)
|
||||
}
|
||||
|
||||
return fq, nil
|
||||
}
|
||||
|
||||
func parseTime(s string) string {
|
||||
t, err := time.Parse(time.DateTime, s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(time.DateTime)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int64 `json:"id"`
|
||||
Content string `json:"content"`
|
||||
Title string `json:"title"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Version int `json:"version"`
|
||||
Comments []Comment `json:"comments"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type PostWithMetadata struct {
|
||||
Post
|
||||
CommentCount int `json:"comment_count"`
|
||||
}
|
||||
|
||||
type PostStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *PostStore) Create(ctx context.Context, post *Post) error {
|
||||
query := `
|
||||
INSERT INTO posts (content, title, user_id, tags)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, created_at, updated_at
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := s.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
post.Content,
|
||||
post.Title,
|
||||
post.UserID,
|
||||
pq.Array(post.Tags),
|
||||
).Scan(&post.ID, &post.CreatedAt, &post.UpdatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostStore) GetByID(ctx context.Context, id int64) (*Post, error) {
|
||||
query := `
|
||||
SELECT id, content, title, user_id, tags, created_at, updated_at, version
|
||||
FROM posts
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
var post Post
|
||||
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&post.ID,
|
||||
&post.Content,
|
||||
&post.Title,
|
||||
&post.UserID,
|
||||
pq.Array(&post.Tags),
|
||||
&post.CreatedAt,
|
||||
&post.UpdatedAt,
|
||||
&post.Version,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
func (s *PostStore) GetUserFeed(ctx context.Context, userID int64, fq PaginatedFeedQuery) ([]PostWithMetadata, error) {
|
||||
query := `
|
||||
SELECT
|
||||
p.id, p.user_id, p.title, p.content, p.created_at, p.updated_at, p.version, p.tags,
|
||||
u.username,
|
||||
COUNT(c.id) AS comments_count
|
||||
FROM posts p
|
||||
LEFT JOIN comments c on c.post_id = p.id
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
JOIN followers f ON f.follower_id = p.user_id OR p.user_id = $1
|
||||
WHERE
|
||||
f.user_id = $1 AND
|
||||
(p.title ILIKE '%' || $4 || '%' OR p.content ILIKE '%' || $4 || '%') AND
|
||||
(p.tags @> $5 OR $5 = '{}')
|
||||
GROUP BY p.id, u.username
|
||||
ORDER BY p.created_at ` + fq.Sort + `
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, userID, fq.Limit, fq.Offset, fq.Search, pq.Array(fq.Tags))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var feed []PostWithMetadata
|
||||
for rows.Next() {
|
||||
var p PostWithMetadata
|
||||
err := rows.Scan(
|
||||
&p.ID,
|
||||
&p.UserID,
|
||||
&p.Title,
|
||||
&p.Content,
|
||||
&p.CreatedAt,
|
||||
&p.UpdatedAt,
|
||||
&p.Version,
|
||||
pq.Array(&p.Tags),
|
||||
&p.User.Username,
|
||||
&p.CommentCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Println(p)
|
||||
|
||||
feed = append(feed, p)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return feed, nil
|
||||
|
||||
}
|
||||
|
||||
func (s *PostStore) Update(ctx context.Context, post *Post) error {
|
||||
query := `
|
||||
UPDATE posts
|
||||
SET content = $1, title = $2, updated_at = NOW(), version = version + 1
|
||||
WHERE id = $3 AND version = $4
|
||||
RETURNING version
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
err := s.db.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
post.Content,
|
||||
post.Title,
|
||||
post.ID,
|
||||
post.Version,
|
||||
).Scan(&post.Version)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
return ErrNotFound
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostStore) Delete(ctx context.Context, id int64) error {
|
||||
query := `
|
||||
DELETE FROM posts
|
||||
WHERE id = $1
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
res, err := s.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type Role struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Level int64 `json:"level"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type RoleStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *RoleStore) GetByName(ctx context.Context, name string) (*Role, error) {
|
||||
query := `
|
||||
SELECT id, name, level, description
|
||||
FROM roles
|
||||
WHERE name = $1
|
||||
`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
role := &Role{}
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, name).Scan(
|
||||
&role.ID,
|
||||
&role.Name,
|
||||
&role.Level,
|
||||
&role.Description,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return role, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
QueryTimeout = 5 * time.Second
|
||||
ErrNotFound = errors.New("resource not found")
|
||||
ErrConflict = errors.New("resource already exists")
|
||||
ErrDuplicateEmail = errors.New("email already exists")
|
||||
ErrDuplicateUsername = errors.New("username already exists")
|
||||
ErrPasswordNotSet = errors.New("password is empty")
|
||||
)
|
||||
|
||||
type Storage struct {
|
||||
Posts interface {
|
||||
Create(context.Context, *Post) error
|
||||
GetByID(context.Context, int64) (*Post, error)
|
||||
GetUserFeed(context.Context, int64, PaginatedFeedQuery) ([]PostWithMetadata, error)
|
||||
Update(context.Context, *Post) error
|
||||
Delete(context.Context, int64) error
|
||||
}
|
||||
Users interface {
|
||||
Create(context.Context, *sql.Tx, *User) error
|
||||
Activate(context.Context, string) error
|
||||
GetByID(context.Context, int64) (*User, error)
|
||||
GetByEmail(context.Context, string) (*User, error)
|
||||
Update(context.Context, *User) error
|
||||
CreateAndInvite(context.Context, *User, string, time.Duration) error
|
||||
Delete(context.Context, int64) error
|
||||
DeleteUser(context.Context, int64) error
|
||||
}
|
||||
Comments interface {
|
||||
Create(context.Context, *Comment) error
|
||||
GetByPostID(context.Context, int64) ([]Comment, error)
|
||||
}
|
||||
Followers interface {
|
||||
Follow(context.Context, int64, int64) error
|
||||
Unfollow(context.Context, int64, int64) error
|
||||
}
|
||||
Roles interface {
|
||||
GetByName(context.Context, string) (*Role, error)
|
||||
}
|
||||
}
|
||||
|
||||
func NewStorage(db *sql.DB) Storage {
|
||||
return Storage{
|
||||
Posts: &PostStore{db},
|
||||
Users: &UserStore{db},
|
||||
Comments: &CommentStore{db},
|
||||
Followers: &FollowerStore{db},
|
||||
Roles: &RoleStore{db},
|
||||
}
|
||||
}
|
||||
|
||||
func withTx(db *sql.DB, ctx context.Context, f func(*sql.Tx) error) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := f(tx); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password password `json:"-"`
|
||||
IsActive bool `json:"is_active"`
|
||||
RoleID int64 `json:"role_id"`
|
||||
Role Role `json:"role"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type password struct {
|
||||
text *string `validate:"required,password"`
|
||||
hash []byte
|
||||
}
|
||||
|
||||
func (p *password) Set(password string) error {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.text = &password
|
||||
p.hash = hash
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type UserStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func (s *UserStore) Create(ctx context.Context, tx *sql.Tx, user *User) error {
|
||||
query := `
|
||||
INSERT INTO users (first_name, last_name, username, email, password, role_id)
|
||||
VALUES ($1, $2, $3, $4, $5, (SELECT id FROM roles WHERE name = $6)) RETURNING id, created_at, updated_at
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
role := user.Role.Name
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
err := tx.QueryRowContext(
|
||||
ctx,
|
||||
query,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.Email,
|
||||
user.Password.hash,
|
||||
role,
|
||||
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
|
||||
if err != nil {
|
||||
switch {
|
||||
case err.Error() == `pq: duplicate key value violates unique constraint "users_username_key"`:
|
||||
return ErrDuplicateUsername
|
||||
case err.Error() == `pq: duplicate key value violates unique constraint "users_email_key"`:
|
||||
return ErrDuplicateEmail
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByID(ctx context.Context, id int64) (*User, error) {
|
||||
query := `
|
||||
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
|
||||
FROM users
|
||||
JOIN roles ON (users.role_id = roles.id)
|
||||
WHERE users.id = $1
|
||||
AND is_active = true
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
user := &User{}
|
||||
err := s.db.QueryRowContext(ctx, query, id).Scan(
|
||||
&user.ID,
|
||||
&user.FirstName,
|
||||
&user.LastName,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.Password.hash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.Role.ID,
|
||||
&user.Role.Name,
|
||||
&user.Role.Level,
|
||||
&user.Role.Description,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*User, error) {
|
||||
query := `
|
||||
SELECT users.id, first_name, last_name, username, email, password, created_at, updated_at, roles.*
|
||||
FROM users
|
||||
JOIN roles ON (users.role_id = roles.id)
|
||||
WHERE email = $1
|
||||
AND is_active = true
|
||||
`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
user := &User{}
|
||||
|
||||
err := s.db.QueryRowContext(ctx, query, email).Scan(
|
||||
&user.ID,
|
||||
&user.FirstName,
|
||||
&user.LastName,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.Password.hash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.Role.ID,
|
||||
&user.Role.Name,
|
||||
&user.Role.Level,
|
||||
&user.Role.Description,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) Update(ctx context.Context, user *User) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET first_name = $1, last_name = $2, username = $3, email = $4, password = $5
|
||||
WHERE id = $6
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.Email,
|
||||
user.Password.hash,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) CreateAndInvite(ctx context.Context, user *User, token string, invitationExp time.Duration) error {
|
||||
return withTx(s.db, ctx, func(tx *sql.Tx) error {
|
||||
if err := s.Create(ctx, tx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.createUserInvitation(ctx, tx, user.ID, invitationExp, token); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UserStore) Activate(ctx context.Context, token string) error {
|
||||
return withTx(s.db, ctx, func(tx *sql.Tx) error {
|
||||
// Find the user that this token belongs to
|
||||
user, err := getUserFromInvitation(ctx, tx, token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the user's status to active
|
||||
user.IsActive = true
|
||||
if err := s.update(ctx, tx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the token from the invitations table
|
||||
if err := s.deleteUserInvitation(ctx, tx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UserStore) Delete(ctx context.Context, id int64) error {
|
||||
return withTx(s.db, ctx, func(tx *sql.Tx) error {
|
||||
if err := s.delete(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.deleteUserInvitation(ctx, tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// * Soft delete user
|
||||
func (s *UserStore) DeleteUser(ctx context.Context, id int64) error {
|
||||
query := `UPDATE users SET is_active = false WHERE id = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := s.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *password) Compare(password string) error {
|
||||
return bcrypt.CompareHashAndPassword(p.hash, []byte(password))
|
||||
}
|
||||
|
||||
func getUserFromInvitation(ctx context.Context, tx *sql.Tx, token string) (*User, error) {
|
||||
query := `
|
||||
SELECT u.id, u.first_name, u.last_name, u.username, u.email, u.created_at, u.updated_at, is_active
|
||||
FROM users u
|
||||
JOIN invitations i ON u.id = i.user_id
|
||||
WHERE i.token = $1 AND i.expiry > $2
|
||||
`
|
||||
|
||||
hashed := sha256.Sum256([]byte(token))
|
||||
hashedToken := hex.EncodeToString(hashed[:])
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
user := &User{}
|
||||
err := tx.QueryRowContext(ctx, query, hashedToken, time.Now()).Scan(
|
||||
&user.ID,
|
||||
&user.FirstName,
|
||||
&user.LastName,
|
||||
&user.Username,
|
||||
&user.Email,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&user.IsActive,
|
||||
)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case sql.ErrNoRows:
|
||||
return nil, ErrNotFound
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserStore) createUserInvitation(ctx context.Context, tx *sql.Tx, userID int64, exp time.Duration, token string) error {
|
||||
query := `
|
||||
INSERT INTO invitations (user_id, token, expiry)
|
||||
VALUES ($1, $2, $3)
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := tx.ExecContext(ctx, query, userID, token, time.Now().Add(exp))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) update(ctx context.Context, tx *sql.Tx, user *User) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET first_name = $1, last_name = $2, username = $3, email = $4, is_active = $5
|
||||
WHERE id = $6
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := tx.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Username,
|
||||
user.Email,
|
||||
user.IsActive,
|
||||
user.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) deleteUserInvitation(ctx context.Context, tx *sql.Tx, userID int64) error {
|
||||
query := `DELETE FROM invitations WHERE user_id = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := tx.ExecContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserStore) delete(ctx context.Context, tx *sql.Tx, id int64) error {
|
||||
query := `DELETE FROM users WHERE id = $1`
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := tx.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user