diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..7f8a808 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +jobs: + deploy: + name: Deploy Website + runs-on: ubuntu-latest + steps: + - name: Remote deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + username: ${{ secrets.SSH_USER }} + script: | + source ~/.bash_profile + cd ~/projects/codejam2024/ + git reset --hard HEAD || true + git pull origin main + ./build.sh + sudo systemctl restart codejam-staging.service + +name: Deploy + +on: + push: + branches: + - main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9cba972 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,23 @@ +jobs: + deploy: + name: Deploy Prod Release + runs-on: ubuntu-latest + steps: + - name: Remote deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.SSH_HOST }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.SSH_PORT }} + username: ${{ secrets.SSH_USER }} + script: | + source ~/.bash_profile + sudo systemctl stop codejam-prod.service + sudo cp /opt/codejam/staging/codejam.io /opt/codejam/prod/ + sudo systemctl start codejam-prod.service + +name: Release + +on: + release: + types: [published] diff --git a/.gitignore b/.gitignore index d4ae893..2e564db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,18 @@ .idea/ *.iml +node_modules + # VSCODE .vscode/ # Config files... config.toml + +# extra files to ignore +codejam.io.exe +brainstorm.txt +gradientchange.svelte + +# docker related files +docker-compose.override.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c91fde --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM library/node:20-alpine3.20 as frontend_builder + +WORKDIR /app + +COPY ./html/package.json /app +COPY ./html/package-lock.json /app + +RUN npm install + +COPY ./html /app + +RUN npm run build + +RUN mkdir /app/dist/static/ +RUN cp --recursive /app/static/. /app/dist/static/ + +FROM library/golang:1.22.3-alpine3.20 + +WORKDIR /app + +COPY ./backend/app/go.mod /app +COPY ./backend/app/go.sum /app + +RUN go mod download + +COPY ./backend/app/ /app +COPY --from=frontend_builder /app/dist/ /app/server/static_files/ + +ENTRYPOINT [ "go", "run", "./main.go" ] diff --git a/README.md b/README.md index 9f9713f..2f6917a 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# CodeJam Site \ No newline at end of file +# CodeJam Site + +## docker compose +### Deploying + +1. Pull all images + +```bash +docker compose pull database redis +``` + +2. Build the docker image: `docker compose build server` + +```bash +docker compose build server +``` + +3. Copy the example configuration file from `./backend/app/config.example.toml` to `./backend/app/config.toml` +4. Edit the configuration file + 1. The database user will be "postgres" + 2. The database host will be "database" (as the name of the service in the `docker-compose.yaml` file) + 3. The redis host will be "redis" (as the name of the service in the `docker-compose.yaml` file) +5. Start the PostgreSQL and Redis containers + +```bash +docker compose up -d database redis +``` + +6. Start the docker container through docker compose + +```bash +docker compose up -d server +``` + +7. Visit website at [http://localhost:8080](http://localhost:8080) + +### Providing more overrides + +1. Unignore the `docker-compose.override.yaml` in the `.gitignore` file +2. Make changes +3. Stage the updated `docker-compose.override.yaml` file +4. Ignore the `docker-compose.override.yaml` file in the `.gitignore` file diff --git a/backend/app/database/events.go b/backend/app/database/events.go index 398d7f4..ad3f84e 100644 --- a/backend/app/database/events.go +++ b/backend/app/database/events.go @@ -1,6 +1,8 @@ package database -import "github.com/jackc/pgx/v5/pgtype" +import ( + "github.com/jackc/pgx/v5/pgtype" +) type DBEvent struct { Id pgtype.UUID `db:"id"` @@ -8,26 +10,49 @@ type DBEvent struct { Title string `db:"title"` Description string `db:"description"` Rules string `db:"rules"` - OrganizerUserId pgtype.UUID `db:"organizer_user_id"` + Timeline string `db:"timeline"` + OrganizerUserId pgtype.UUID `db:"organizer_user_id" json:"-"` MaxTeams int `db:"max_teams"` StartsAt pgtype.Timestamp `db:"starts_at"` EndsAt pgtype.Timestamp `db:"ends_at"` - CreatedOn pgtype.Timestamp `db:"created_on"` + CreatedOn pgtype.Timestamp `db:"created_on" json:"-"` +} + +type DBEventStatus struct { + Id int `db:"id"` + Code string `db:"code"` + Title string `db:"title"` + Description string `db:"description"` + Sort int `db:"sort"` +} + +type DBEventStatusCode struct { + Code string `db:"code"` +} + +func GetEventStatusCode(eventId pgtype.UUID) (string, error) { + result, err := GetRow[DBEventStatusCode]( + `SELECT code + FROM events + INNER JOIN statuses ON (events.status_id = statuses.id) + WHERE events.id = $1`, + eventId) + return result.Code, err } func CreateEvent(organizerUserId pgtype.UUID) (DBEvent, error) { var event DBEvent event, err := GetRow[DBEvent]( `INSERT INTO events - (status_id, title, description, rules, organizer_user_id) - VALUES - ((SELECT id FROM statuses WHERE code = 'PLANNING'), + (status_id, title, description, rules, organizer_user_id) + VALUES + ((SELECT id FROM statuses WHERE code = 'PLANNING'), '', '', '', $1) RETURNING * - `, + `, organizerUserId) return event, err } @@ -44,18 +69,35 @@ func GetEvents() ([]DBEvent, error) { return result, err } +// GetActiveEvent will return what is assumed to be a single active event. +func GetActiveEvent() (DBEvent, error) { + result, err := GetRow[DBEvent]( + `SELECT * FROM events + WHERE status_id IN ( + SELECT id FROM statuses WHERE code NOT IN ('PLANNING', 'ENDED') + ) + LIMIT 1`) + return result, err +} + func UpdateEvent(event DBEvent) (DBEvent, error) { event, err := GetRow[DBEvent]( `UPDATE events SET status_id=$2, title=$3, - description=$4, - rules=$5, - max_teams=$6, - starts_at=$7, - ends_at=$8 + timeline=$4, + description=$5, + rules=$6, + max_teams=$7, + starts_at=$8, + ends_at=$9 WHERE id=$1 RETURNING *`, - event.Id, event.StatusId, event.Title, event.Description, event.Rules, event.MaxTeams, event.StartsAt, event.EndsAt) + event.Id, event.StatusId, event.Title, event.Timeline, event.Description, event.Rules, event.MaxTeams, event.StartsAt, event.EndsAt) return event, err } + +func GetStatuses() ([]DBEventStatus, error) { + statuses, err := GetRows[DBEventStatus](`SELECT * FROM statuses ORDER BY sort`) + return statuses, err +} diff --git a/backend/app/database/migrations.go b/backend/app/database/migrations.go new file mode 100644 index 0000000..6ac4658 --- /dev/null +++ b/backend/app/database/migrations.go @@ -0,0 +1,39 @@ +package database + +import ( + "embed" + "github.com/golang-migrate/migrate/v4" + "github.com/golang-migrate/migrate/v4/database/pgx/v5" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/jackc/pgx/v5/stdlib" + "os" +) + +//go:embed migrations/* +var migrationsFS embed.FS + +func RunMigrations() { + logger.Info("Running DB Migrations...") + stdConn := stdlib.OpenDBFromPool(Pool) + + migrationConfig := pgx.Config{} + sqlDriver, err := pgx.WithInstance(stdConn, &migrationConfig) + if err != nil { + logger.Critical("Error setting up migrations DB connection: %v", err) + os.Exit(1) + } + + sourceDriver, err := iofs.New(migrationsFS, "migrations") + if err != nil { + logger.Critical("Error setting up migrations FS: %v", err) + os.Exit(1) + } + + m, err := migrate.NewWithInstance("embeddedFS", sourceDriver, "postgres", sqlDriver) + err = m.Up() + if err != nil && err != migrate.ErrNoChange { + logger.Critical("Migrations failure: %v", err) + os.Exit(1) + } + logger.Info("Migrations completed") +} diff --git a/backend/app/database/migrations/00000000_Initial.up.sql b/backend/app/database/migrations/00000000_Initial.up.sql new file mode 100644 index 0000000..6f84fec --- /dev/null +++ b/backend/app/database/migrations/00000000_Initial.up.sql @@ -0,0 +1,49 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS users ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + service_name TEXT NOT NULL, + service_user_id TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + created_on TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE('utc')) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_service_user ON users (service_name, service_user_id); + +CREATE TABLE IF NOT EXISTS events ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + status_id integer, + title TEXT NOT NULL, + description TEXT NOT NULL, + rules TEXT NOT NULL, + organizer_user_id UUID NOT NULL, + max_teams integer default -1, + starts_at TIMESTAMP WITH TIME ZONE, + ends_at TIMESTAMP WITH TIME ZONE, + created_on TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE('utc')) +); + +CREATE TABLE IF NOT EXISTS statuses ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL +); + +INSERT INTO statuses (id, code, title, description) VALUES (1, 'PLANNING', 'Planning', 'Event is not yet live. The organizer may edit and preview the site but normal users will just see a placeholder page') +ON CONFLICT (id) DO UPDATE set code = EXCLUDED.code, title = EXCLUDED.title, description = EXCLUDED.description; + +INSERT INTO statuses (id, code, title, description) VALUES (2, 'PUBLISHED', 'Published', 'Event is publicly visible.') +ON CONFLICT (id) DO UPDATE set code = EXCLUDED.code, title = EXCLUDED.title, description = EXCLUDED.description; + +INSERT INTO statuses (id, code, title, description) VALUES (3, 'STARTED', 'Started', 'The theme will be shown publicly and users can start coding. Submissions will be accepted during this time.') +ON CONFLICT (id) DO UPDATE set code = EXCLUDED.code, title = EXCLUDED.title, description = EXCLUDED.description; + +INSERT INTO statuses (id, code, title, description) VALUES (4, 'VOTING', 'Voting Open', 'Submissions are closed and voting is underway') +ON CONFLICT (id) DO UPDATE set code = EXCLUDED.code, title = EXCLUDED.title, description = EXCLUDED.description; + +INSERT INTO statuses (id, code, title, description) VALUES (5, 'ENDED', 'Ended', 'Competition is over. Results may still be viewed, but no more voting is allowed') +ON CONFLICT (id) DO UPDATE set code = EXCLUDED.code, title = EXCLUDED.title, description = EXCLUDED.description; + + + diff --git a/backend/app/database/migrations/00000001_event_adds.down.sql b/backend/app/database/migrations/00000001_event_adds.down.sql new file mode 100644 index 0000000..9974750 --- /dev/null +++ b/backend/app/database/migrations/00000001_event_adds.down.sql @@ -0,0 +1 @@ +ALTER TABLE events DROP COLUMN timeline; \ No newline at end of file diff --git a/backend/app/database/migrations/00000001_event_adds.up.sql b/backend/app/database/migrations/00000001_event_adds.up.sql new file mode 100644 index 0000000..b327424 --- /dev/null +++ b/backend/app/database/migrations/00000001_event_adds.up.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN timeline TEXT DEFAULT ''; diff --git a/backend/app/database/migrations/00000002_user_role.down.sql b/backend/app/database/migrations/00000002_user_role.down.sql new file mode 100644 index 0000000..013fe61 --- /dev/null +++ b/backend/app/database/migrations/00000002_user_role.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN IF EXISTS role; \ No newline at end of file diff --git a/backend/app/database/migrations/00000002_user_role.up.sql b/backend/app/database/migrations/00000002_user_role.up.sql new file mode 100644 index 0000000..0d9a26e --- /dev/null +++ b/backend/app/database/migrations/00000002_user_role.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN role TEXT DEFAULT ''; \ No newline at end of file diff --git a/backend/app/database/migrations/00000003_teams.up.sql b/backend/app/database/migrations/00000003_teams.up.sql new file mode 100644 index 0000000..7c239b5 --- /dev/null +++ b/backend/app/database/migrations/00000003_teams.up.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS teams ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + event_id UUID references events(id), + owner_user_id UUID references users(id) ON DELETE NO ACTION ON UPDATE NO ACTION, + name TEXT NOT NULL, + visibility TEXT, + timezone TEXT, + technologies TEXT, + availability TEXT NOT NULL, + description TEXT NOT NULL, + created_on TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE('utc')) +); \ No newline at end of file diff --git a/backend/app/database/migrations/00000004_team_members.up.sql b/backend/app/database/migrations/00000004_team_members.up.sql new file mode 100644 index 0000000..5429dbd --- /dev/null +++ b/backend/app/database/migrations/00000004_team_members.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS team_members ( + team_id UUID NOT NULL references teams(id) ON DELETE CASCADE, + user_id UUID NOT NULL references users(id) ON DELETE CASCADE, + team_role TEXT NOT NULL, + created_on TIMESTAMP WITH TIME ZONE DEFAULT (now() AT TIME ZONE('utc')), + PRIMARY KEY (team_id, user_id) +); + +ALTER TABLE teams DROP COLUMN IF EXISTS owner_user_id; + diff --git a/backend/app/database/migrations/00000005_team_role.down.sql b/backend/app/database/migrations/00000005_team_role.down.sql new file mode 100644 index 0000000..6465999 --- /dev/null +++ b/backend/app/database/migrations/00000005_team_role.down.sql @@ -0,0 +1 @@ +ALTER TABLE team_members RENAME COLUMN role TO team_role; \ No newline at end of file diff --git a/backend/app/database/migrations/00000006_team_code.up.sql b/backend/app/database/migrations/00000006_team_code.up.sql new file mode 100644 index 0000000..438181e --- /dev/null +++ b/backend/app/database/migrations/00000006_team_code.up.sql @@ -0,0 +1 @@ +ALTER TABLE teams ADD COLUMN code TEXT; \ No newline at end of file diff --git a/backend/app/database/migrations/00000007_team_code.down.sql b/backend/app/database/migrations/00000007_team_code.down.sql new file mode 100644 index 0000000..1729988 --- /dev/null +++ b/backend/app/database/migrations/00000007_team_code.down.sql @@ -0,0 +1 @@ +ALTER TABLE teams RENAME code TO invite_code; \ No newline at end of file diff --git a/backend/app/database/migrations/00000008_team_code.down.sql b/backend/app/database/migrations/00000008_team_code.down.sql new file mode 100644 index 0000000..e688cfd --- /dev/null +++ b/backend/app/database/migrations/00000008_team_code.down.sql @@ -0,0 +1 @@ +ALTER TABLE teams DROP COLUMN code; \ No newline at end of file diff --git a/backend/app/database/migrations/00000009_avatars.up.sql b/backend/app/database/migrations/00000009_avatars.up.sql new file mode 100644 index 0000000..dda99f4 --- /dev/null +++ b/backend/app/database/migrations/00000009_avatars.up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar_url TEXT; \ No newline at end of file diff --git a/backend/app/database/migrations/00000010_signup_status.down.sql b/backend/app/database/migrations/00000010_signup_status.down.sql new file mode 100644 index 0000000..0b44460 --- /dev/null +++ b/backend/app/database/migrations/00000010_signup_status.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE statuses DROP COLUMN sort; + +DELETE FROM statuses WHERE code in ('SIGNUP', 'COMPLETED'); \ No newline at end of file diff --git a/backend/app/database/migrations/00000010_signup_status.up.sql b/backend/app/database/migrations/00000010_signup_status.up.sql new file mode 100644 index 0000000..9dfae8b --- /dev/null +++ b/backend/app/database/migrations/00000010_signup_status.up.sql @@ -0,0 +1,12 @@ +ALTER TABLE statuses ADD COLUMN IF NOT EXISTS sort int; + +INSERT INTO statuses (id, code, title, description) VALUES (6, 'SIGNUP', 'Signups Accepted', 'Users may begin signing up and creating teams.'); +INSERT INTO statuses (id, code, title, description) VALUES (7, 'COMPLETED', 'Complete', 'Competition is over. Results may still be viewed, but not more voting is allowed'); + +UPDATE statuses SET sort = 1 WHERE code='PLANNING'; +UPDATE statuses SET sort = 2 WHERE code='PUBLISHED'; +UPDATE statuses SET sort = 3 WHERE code='SIGNUP'; +UPDATE statuses SET sort = 4 WHERE code='STARTED'; +UPDATE statuses SET sort = 5 WHERE code='ENDED'; +UPDATE statuses SET sort = 6 WHERE code='VOTING'; +UPDATE statuses SET sort = 7 WHERE code='COMPLETED'; diff --git a/backend/app/database/migrations/00000011_admin_users.up.sql b/backend/app/database/migrations/00000011_admin_users.up.sql new file mode 100644 index 0000000..9b334a7 --- /dev/null +++ b/backend/app/database/migrations/00000011_admin_users.up.sql @@ -0,0 +1,12 @@ +-- capture the original user so admins can see it without having to lookup a discord id +ALTER TABLE users ADD COLUMN IF NOT EXISTS service_user_name TEXT; + +-- allow for a user to be disabled or banned, or have some other sort of status that's TBD +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_status TEXT DEFAULT 'ACTIVE'; + +-- allow for a user to be prevented from modifying their display name +ALTER TABLE users ADD COLUMN IF NOT EXISTS lock_display_name BOOLEAN DEFAULT FALSE; + +-- assumes no real users are in the system yet +UPDATE users SET service_user_name = display_name; + diff --git a/backend/app/database/postgres.go b/backend/app/database/postgres.go index 6b25020..330032a 100644 --- a/backend/app/database/postgres.go +++ b/backend/app/database/postgres.go @@ -81,3 +81,21 @@ func GetRows[T any](query string, args ...any) ([]T, error) { return result, nil } + +func Execute(query string, args ...any) error { + conn, err := Pool.Acquire(context.Background()) + if err != nil { + logger.Error("acquire conn error %v", err) + return err + } + defer conn.Release() + + _, err = conn.Query(context.Background(), + query, + args...) + if err != nil { + logger.Error("Query Execute Error: %v", err) + } + + return nil +} diff --git a/backend/app/database/structs.go b/backend/app/database/structs.go new file mode 100644 index 0000000..adc142b --- /dev/null +++ b/backend/app/database/structs.go @@ -0,0 +1,139 @@ +package database + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type DBTeam struct { + Id pgtype.UUID `db:"id"` + EventId pgtype.UUID `db:"event_id"` + Name string `db:"name"` + Visibility string `db:"visibility"` + Timezone string `db:"timezone"` + Technologies string `db:"technologies"` + Availability string `db:"availability"` + Description string `db:"description"` + CreatedOn pgtype.Timestamptz `db:"created_on"` + InviteCode string `db:"invite_code"` +} + +type CreateTeamMember struct { + UserId pgtype.UUID `db:"user_id"` + TeamId pgtype.UUID `db:"team_id"` + TeamRole string `db:"team_role"` +} + +// has all the user info & role to pass to be read client-side +type DBTeamMemberInfo struct { + DBUser // embed the DBUser fields into the struct + TeamRole string `db:"team_role"` +} + +type DBTeamMember struct { + TeamId pgtype.UUID `db:"team_id"` + UserId pgtype.UUID `db:"user_id"` + TeamRole string `db:"team_role"` + CreatedOn pgtype.Timestamp `db:"user_created_on"` +} + +type DBUserTeams struct { + DBTeam + DisplayName string `db:"display_name"` + TeamRole string `db:"team_role"` + AvatarId string `db:"avatar_id"` +} + +type DBTeamAndTeamMember struct { + Id pgtype.UUID `db:"id"` + EventId pgtype.UUID `db:"event_id"` + Name string `db:"name"` + Visibility string `db:"visibility"` + Timezone string `db:"timezone"` + Technologies string `db:"technologies"` + Availability string `db:"availability"` + Description string `db:"description"` + CreatedOn pgtype.Timestamptz `db:"team_created_on"` + InviteCode string `db:"invite_code"` + MembershipCreatedOn pgtype.Timestamptz `db:"membership_created_on"` + TeamId pgtype.UUID `db:"team_id"` + UserId pgtype.UUID `db:"user_id"` + TeamRole string `db:"team_role"` + DisplayName string `db:"display_name"` + AvatarId string `db:"avatar_id"` + ServiceUserId string `db:"service_user_id"` +} + +type UITeam struct { + Id pgtype.UUID `db:"id"` + EventId pgtype.UUID `db:"event_id"` + Name string `db:"name"` + Visibility string `db:"visibility"` + Timezone string `db:"timezone"` + Technologies string `db:"technologies"` + Availability string `db:"availability"` + Description string `db:"description"` + CreatedOn pgtype.Timestamptz `db:"team_created_on"` + InviteCode string `db:"invite_code"` +} + +type UITeamMember struct { + TeamId pgtype.UUID `db:"team_id"` + UserId pgtype.UUID `db:"user_id"` + MembershipCreatedOn pgtype.Timestamptz `db:"membership_created_on"` + TeamRole string `db:"team_role"` +} + +type TeamMember struct { + UITeamMember + DisplayName string `db:"display_name"` + AvatarId string `db:"avatar_id"` + ServiceUserId string `db:"service_user_id"` +} + +type TeamAndMembers struct { + UITeam + TeamMembers []TeamMember +} + +type UserTeamMember struct { + UserId pgtype.UUID `json:"UserId"` + DisplayName string `json:"DisplayName"` + TeamRole string `json:"TeamRole"` + AvatarId string `json:"AvatarId"` + ServiceUserId string `json:"ServiceUserId"` + UserCreatedOn pgtype.Timestamptz `json:"UserCreatedOn"` +} + +type UserTeam struct { + Id pgtype.UUID `json:"Id"` + EventId pgtype.UUID `json:"EventId"` + Name string `json:"Name"` + Description string `json:"Description"` + Visibility string `json:"Visibility"` + Technologies string `json:"Technologies"` + Availability string `json:"Availability"` + TeamCreatedOn pgtype.Timestamptz `json:"TeamCreatedOn"` + InviteCode string `json:"InviteCode"` + TeamMembers []UserTeamMember `json:"TeamMembers"` +} + +type UserTeamAndMembers struct { + Id pgtype.UUID `db:"id"` + EventId pgtype.UUID `db:"event_id"` + Name string `db:"name"` + Visibility string `db:"visibility"` + Timezone string `db:"timezone"` + Technologies string `db:"technologies"` + Availability string `db:"availability"` + Description string `db:"description"` + TeamCreatedOn pgtype.Timestamptz `db:"team_created_on"` + InviteCode string `db:"invite_code"` + TeamId pgtype.UUID `db:"team_id"` + UserId pgtype.UUID `db:"user_id"` + TeamRole string `db:"team_role"` + MembershipCreatedOn pgtype.Timestamptz `db:"membership_created_on"` + DisplayName string `db:"display_name"` + AvatarId string `db:"avatar_id"` + ServiceUserId string `db:"service_user_id"` + CurrentUserRole string `db:"current_user_role"` +} diff --git a/backend/app/database/teams.go b/backend/app/database/teams.go new file mode 100644 index 0000000..c42a806 --- /dev/null +++ b/backend/app/database/teams.go @@ -0,0 +1,319 @@ +package database + +import ( + "fmt" + "github.com/jackc/pgx/v5/pgtype" +) + +func CreateTeam(team DBTeam) (pgtype.UUID, error) { + team, err := GetRow[DBTeam]( + `INSERT INTO teams + (event_id, name, visibility, timezone, technologies, availability, description, invite_code) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, event_id, name, visibility, timezone, technologies, availability, description, created_on, invite_code + `, + team.EventId, team.Name, team.Visibility, team.Timezone, team.Technologies, team.Availability, team.Description, team.InviteCode) + if err != nil { + fmt.Println("ERROR: failed to create team: ", err) + } + return team.Id, err +} + +// stepp 5: used to construct the GetTeamResponse struct in server/teams.go +func GetTeam(teamId pgtype.UUID) (DBTeam, error) { + team, err := GetRow[DBTeam]( + `SELECT + teams.id, + teams.event_id, + teams.name, + teams.visibility, + teams.timezone, + teams.technologies, + teams.availability, + teams.description, + teams.created_on, + teams.invite_code + FROM teams + WHERE teams.id = $1`, + teamId) + // `SELECT * FROM teams WHERE id = $1`, + // teamId) + + if err != nil { + logger.Error("===DB/GetTeam error: ", err) + return DBTeam{}, err + } + return team, nil +} + +func GetTeamByInvite(inviteCode string) (DBTeam, error) { + // "/invite/:invitecode" + team, err := GetRow[DBTeam]( + `SELECT + teams.id, + teams.event_id, + teams.name, + teams.visibility, + teams.timezone, + teams.technologies, + teams.availability, + teams.description, + teams.created_on, + teams.invite_code + FROM teams + WHERE teams.invite_code = $1`, + inviteCode) + if err != nil { + logger.Error("===DB/GetTeamByInvite error: ", err) + return DBTeam{}, err + } + return team, nil +} + +func MapToTeamAndMember(data []DBTeamAndTeamMember) []TeamAndMembers { + // instantiates array to store output, mapped by team id (uuid) for key + teamMap := make(map[pgtype.UUID]*TeamAndMembers) + for _, item := range data { + // Check if team already exists in the map. This map loopkup returns: + // 1) value associated with the key if it exsits + // 2) boolean indicating whether key was found in the map + team, ok := teamMap[item.TeamId] + if !ok { + // Create a new team + team = &TeamAndMembers{ + UITeam: UITeam{ + Id: item.Id, + EventId: item.EventId, + Name: item.Name, + Visibility: item.Visibility, + Timezone: item.Timezone, + Technologies: item.Technologies, + Availability: item.Availability, + Description: item.Description, + CreatedOn: item.CreatedOn, + InviteCode: item.InviteCode, + }, + TeamMembers: []TeamMember{}, + } + teamMap[item.TeamId] = team + } + // Add team member to TeamMembers slice + member := TeamMember{ + UITeamMember: UITeamMember{ + TeamId: item.TeamId, + UserId: item.UserId, + MembershipCreatedOn: item.MembershipCreatedOn, + TeamRole: item.TeamRole, + }, + DisplayName: item.DisplayName, + AvatarId: item.AvatarId, + ServiceUserId: item.ServiceUserId, + } + team.TeamMembers = append(team.TeamMembers, member) + } + // Convert map back to slice + var result []TeamAndMembers + for _, team := range teamMap { + result = append(result, *team) + } + fmt.Println(result) + return result +} + +func GetTeams() (*[]TeamAndMembers, error) { + teamAndMember, err := GetRows[DBTeamAndTeamMember]( // returns { team 1: { userA: {display_name: "momo"}}, team 1...} + `SELECT + t.id, + t.event_id, + t.name, + t.visibility, + t.timezone, + t.technologies, + t.availability, + t.description, + t.created_on AS team_created_on, + t.invite_code, + u.display_name, + u.avatar_id, + u.service_user_id, + tm.team_id, + tm.user_id, + tm.created_on AS membership_created_on, + tm.team_role + FROM teams t + INNER JOIN team_members tm ON (tm.team_id = t.id) + INNER JOIN users u ON (u.id = tm.user_id) + ORDER BY t.id + `, + ) + if err != nil { + return nil, err + } + for _, t := range teamAndMember { + fmt.Printf("%v\n", t) + } + UITeamAndMember := MapToTeamAndMember(teamAndMember) + + return &UITeamAndMember, err +} + +func MapToUserTeamAndMember(userTeams *[]UserTeamAndMembers) []UserTeam { + // initialize a json dictionary + teamMap := make(map[pgtype.UUID]*UserTeam) + + // loop through all the rows from the query. + // UserTeams aka []UserTeam is a single row from query. + for _, ut := range *userTeams { + // If team doesn't exist in map, create it + if _, exists := teamMap[ut.Id]; !exists { + teamMap[ut.Id] = &UserTeam{ + Id: ut.Id, + EventId: ut.EventId, + Name: ut.Name, + Description: ut.Description, + Visibility: ut.Visibility, + Technologies: ut.Technologies, + Availability: ut.Availability, + TeamCreatedOn: ut.TeamCreatedOn, + InviteCode: ut.InviteCode, + TeamMembers: []UserTeamMember{}, + } + } + + // create a member + TeamMember := UserTeamMember{ + UserId: ut.UserId, + DisplayName: ut.DisplayName, + TeamRole: ut.TeamRole, + AvatarId: ut.AvatarId, + ServiceUserId: ut.ServiceUserId, + UserCreatedOn: ut.MembershipCreatedOn, + } + + // add member to teamMap dictionary + teamMap[ut.Id].TeamMembers = append(teamMap[ut.Id].TeamMembers, TeamMember) + } + + // Convert map to slice + teams := make([]UserTeam, 0, len(teamMap)) + for _, team := range teamMap { + teams = append(teams, *team) + } + return teams +} + +func GetUserTeams(userId pgtype.UUID) (*[]UserTeam, error) { + result, err := GetRows[UserTeamAndMembers]( + `SELECT + t.id, + t.event_id, + t.name, + t.visibility, + t.timezone, + t.technologies, + t.availability, + t.description, + t.created_on AS team_created_on, + t.invite_code, + tm.team_id, + tm.user_id, + tm.team_role, + tm.created_on AS membership_created_on, + tmu.display_name, + tmu.avatar_id, + tmu.service_user_id, + mtm.team_role AS current_user_role + FROM team_members mtm + INNER JOIN teams t ON t.id = mtm.team_id + INNER JOIN team_members tm ON t.id = tm.team_id + INNER JOIN users tmu ON tmu.id = tm.user_id + WHERE mtm.user_id = $1 + ORDER BY team_created_on`, + userId) + if err != nil { + logger.Error("failed to retrieve team info for user: %v", userId, err) + } + + UIUserTeamAndMember := MapToUserTeamAndMember(&result) + return &UIUserTeamAndMember, err +} + +func UpdateTeam(team DBTeam) (DBTeam, error) { + updatedTeam, err := GetRow[DBTeam]( + `UPDATE teams + SET name=$2, + visibility=$3, + timezone=$4, + technologies=$5, + availability=$6, + description=$7 + WHERE id=$1 + RETURNING *`, + team.Id, team.Name, team.Visibility, team.Timezone, team.Technologies, team.Availability, team.Description) + if err != nil { + return DBTeam{}, fmt.Errorf("failed to update team with ID %v: %w", team.Id, err) + } + return updatedTeam, err +} + +type DBTeamInviteCode struct { + InviteCode string `db:"invite_code"` +} + +func GetTeamInviteCode(teamId pgtype.UUID) (inviteCode DBTeamInviteCode, err error) { + fmt.Println(teamId) + teamInviteCode, err := GetRow[DBTeamInviteCode]( + `SELECT teams.invite_code + FROM teams + WHERE teams.id = $1`, teamId) + if err != nil { + logger.Error("failed to retrieve invite code for teamId %v: %w", teamId, err) + } + return teamInviteCode, err +} + +// fields: userid, teamid, role +// called at server/teams.go createTeam & when someone clicks "join team" +// DONT MESS WITH BELOW. IT WORKS. +func AddTeamMember(userId pgtype.UUID, teamUUID pgtype.UUID, role string) (userID pgtype.UUID, err error) { + // userId prints something like: {[22 162 173 240 222 76 79 42 174 62 196 207 243 22 25 78] true} + + teamMember, err := GetRow[CreateTeamMember]( + `INSERT INTO team_members + (user_id, team_id, team_role) + VALUES ($1, $2, $3) + RETURNING user_id, team_id, team_role`, userId, teamUUID, role) + if err != nil { + fmt.Println(err) + return userId, err + } + return teamMember.UserId, err +} + +func RemoveTeamMember(teamId pgtype.UUID, userId pgtype.UUID) (DBTeamMember, error) { + deletedMember, err := GetRow[DBTeamMember](` + DELETE FROM team_members + WHERE team_id = $1 AND user_id = $2 + RETURNING team_id, user_id, team_role, created_on AS user_created_on`, + teamId, userId) + if err != nil { + logger.Error("Failed to remove team member: %v", err) + return DBTeamMember{}, err + } + return deletedMember, nil +} + +func GetMembersByTeamId(teamId pgtype.UUID) (*[]DBTeamMemberInfo, error) { + // In Go, you never return slice-data. + // Having * in sig means I'm returning the slice-header, which means I need & in my return + // Not having * means I'm returning a small copy of the slice-header, no need for & in my return + members, err := GetRows[DBTeamMemberInfo]( + // select all the info of a user (a user row) and their tm.role () + `SELECT u.*, tm.team_role + FROM team_members tm + INNER JOIN users u on (u.id = tm.user_id) + WHERE tm.team_id = $1`, + teamId) + return &members, err +} diff --git a/backend/app/database/users.go b/backend/app/database/users.go index 9f280a2..e2a2101 100644 --- a/backend/app/database/users.go +++ b/backend/app/database/users.go @@ -1,40 +1,118 @@ package database import ( + "fmt" + "github.com/emicklei/pgtalk/convert" "github.com/jackc/pgx/v5/pgtype" ) type DBUser struct { - Id pgtype.UUID `db:"id" ` - ServiceName string `db:"service_name"` - ServiceUserId string `db:"service_user_id" json:"-"` - DisplayName string `db:"display_name"` - CreatedOn pgtype.Timestamp `db:"created_on" json:"-"` + Id pgtype.UUID `db:"id"` + ServiceName string `db:"service_name"` + ServiceUserId string `db:"service_user_id"` + ServiceUserName string `db:"service_user_name"` + Role string `db:"role"` + DisplayName string `db:"display_name"` + AvatarId *string `db:"avatar_id"` + AccountStatus string `db:"account_status"` + LockDisplayName bool `db:"lock_display_name"` + CreatedOn pgtype.Timestamp `db:"created_on" json:"-"` } -func CreateUser(serviceName string, serviceUserId string, serviceDisplayName string) DBUser { +// Roles +const ( + Admin = "ADMIN" +) + +func CreateUser(serviceName string, serviceUserId string, serviceDisplayName string, avatarId string) DBUser { user, err := GetRow[DBUser]( - `INSERT INTO users (service_name, service_user_id, display_name) - VALUES ($1, $2, $3) - ON CONFLICT (service_name, service_user_id) - DO UPDATE - SET display_name = $3 - RETURNING *`, - serviceName, serviceUserId, serviceDisplayName) + `INSERT INTO users (service_name, service_user_id, service_user_name, display_name, avatar_id) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (service_name, service_user_id) + DO UPDATE + SET service_user_name = $3, avatar_id = $5 + RETURNING service_name, service_user_id, display_name, avatar_id`, + serviceName, serviceUserId, serviceDisplayName, avatarId) if err != nil { + fmt.Println(serviceName, serviceUserId, serviceDisplayName, avatarId) logger.Error("error getting user: %v", err) } return user } -func GetUser(userId pgtype.UUID) DBUser { +func GetUser(userId pgtype.UUID) (DBUser, error) { user, err := GetRow[DBUser]( `SELECT * - FROM users - WHERE id = $1`, + FROM users + WHERE id = $1`, userId) if err != nil { - logger.Error("error getting user: %v", err) + logger.Error("error getting user: id: %s, error: %v", convert.UUIDToString(userId), err) } - return user + return user, err +} + +func UpdateUser(user DBUser) (DBUser, error) { + user, err := GetRow[DBUser]( + `UPDATE users + SET display_name = $2 + WHERE id = $1 + RETURNING *`, + user.Id, user.DisplayName) + if err != nil { + logger.Error("error updating user: %v", err) + } + return user, err +} + +func GetAllUsers() ([]DBUser, error) { + users, err := GetRows[DBUser]( + `SELECT * + FROM users + ORDER BY ( + CASE -- put empty roles after actual roles + WHEN role is not null and length(role) > 0 then role + ELSE 'zzz' + END + ), service_user_name`) + return users, err +} + +func SetAccountStatus(userId pgtype.UUID, status string) (DBUser, error) { + user, err := GetRow[DBUser]( + `UPDATE users + SET account_status = $2 + WHERE id= $1 + RETURNING *`, + userId, status) + if err != nil { + logger.Error("SetAccountStatus Error: %v", err) + } + return user, err +} + +func SetDisplayName(userId pgtype.UUID, displayName string) (DBUser, error) { + user, err := GetRow[DBUser]( + `UPDATE users + SET display_name = $2 + WHERE id = $1 + RETURNING *`, + userId, displayName) + if err != nil { + logger.Error("SetDisplayName Error: %v", err) + } + return user, err +} + +func SetDisplayNameLock(userId pgtype.UUID, locked bool) (DBUser, error) { + user, err := GetRow[DBUser]( + `UPDATE users + SET lock_display_name = $2 + WHERE id = $1 + RETURNING *`, + userId, locked) + if err != nil { + logger.Error("SetDisplayNameLock Error: %v", err) + } + return user, err } diff --git a/backend/app/go.mod b/backend/app/go.mod index f12678e..513aa57 100644 --- a/backend/app/go.mod +++ b/backend/app/go.mod @@ -23,12 +23,16 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/golang-migrate/migrate/v4 v4.17.1 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/gomodule/redigo v2.0.0+incompatible // indirect github.com/google/uuid v1.5.0 // indirect github.com/gorilla/context v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/sessions v1.2.2 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect @@ -39,9 +43,11 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mrz1836/go-sanitize v1.3.2 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.7.0 // indirect golang.org/x/arch v0.7.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/net v0.22.0 // indirect diff --git a/backend/app/go.sum b/backend/app/go.sum index ebd9d76..93d68c5 100644 --- a/backend/app/go.sum +++ b/backend/app/go.sum @@ -35,10 +35,14 @@ github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4= +github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s= +github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw= github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -58,6 +62,13 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY= github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa h1:s+4MhCQ6YrzisK6hFJUX53drDT4UsSW3DEhKn0ifuHw= +github.com/jackc/pgerrcode v0.0.0-20220416144525-469b46aa5efa/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= @@ -68,6 +79,8 @@ github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jteeuwen/go-bindata v3.0.7+incompatible h1:91Uy4d9SYVr1kyTJ15wJsog+esAZZl7JmEfTkwmhJts= +github.com/jteeuwen/go-bindata v3.0.7+incompatible/go.mod h1:JVvhzYOiGBnFSYRyV00iY8q7/0PThjIYav1p9h5dmKs= github.com/jwalton/go-supportscolor v1.2.0 h1:g6Ha4u7Vm3LIsQ5wmeBpS4gazu0UP1DRDE8y6bre4H8= github.com/jwalton/go-supportscolor v1.2.0/go.mod h1:hFVUAZV2cWg+WFFC4v8pT2X/S2qUUBYMioBD9AINXGs= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -87,6 +100,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mrz1836/go-sanitize v1.3.2 h1:sGhusPxP4L+7NAUVAUl/WrrBazNSNECsvYh3yumuJXQ= +github.com/mrz1836/go-sanitize v1.3.2/go.mod h1:wvRS2ALFDxOCK3ORQPwKUxl7HTIBUV8S3U34Hwn96r4= github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo= github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -109,6 +124,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc= golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= diff --git a/backend/app/integrations/generic.go b/backend/app/integrations/generic.go index 642872d..a651a15 100644 --- a/backend/app/integrations/generic.go +++ b/backend/app/integrations/generic.go @@ -4,6 +4,7 @@ import ( "codejam.io/integrations/discord" "codejam.io/integrations/github" "codejam.io/logging" + "encoding/json" "strings" ) @@ -12,8 +13,8 @@ var logger = logging.NewLogger(logging.Options{Name: "Integrations", Level: logg type IntegrationUser struct { IntegrationName string UserId string - DisplayName string - AvatarUrl string + ServiceUserName string + AvatarId string } func getGitHubUser(accessToken string) *IntegrationUser { @@ -23,21 +24,29 @@ func getGitHubUser(accessToken string) *IntegrationUser { } else { return &IntegrationUser{ IntegrationName: "github", - UserId: user["id"].(string), + UserId: string(user["id"].(json.Number)), } } } func getDiscordUser(accessToken string) *IntegrationUser { user := discord.GetUser(accessToken) + avatar, ok := user["avatar"].(string) + if !ok { + avatar = "" // Or set a default avatar URL if preferred + } if user == nil { logger.Error("User not found for token: %s", accessToken) return nil } else { + if user["avatar"] != nil { + avatar = user["avatar"].(string) + } return &IntegrationUser{ IntegrationName: "discord", UserId: user["id"].(string), - DisplayName: user["global_name"].(string), + ServiceUserName: user["global_name"].(string), + AvatarId: avatar, } } } @@ -51,5 +60,4 @@ func GetUser(integrationName string, accessToken string) *IntegrationUser { default: return nil } - } diff --git a/backend/app/main.go b/backend/app/main.go index cc287a7..5dc2db1 100644 --- a/backend/app/main.go +++ b/backend/app/main.go @@ -1,14 +1,20 @@ package main import ( + "codejam.io/config" "codejam.io/database" + //"codejam.io/logging" + "flag" + "codejam.io/server" "github.com/gin-gonic/gin" ) func main() { + debugArg := flag.Bool("debug", false, "Enable debug mode.") + flag.Parse() // TODO setup logger // Disables the debug logging... Comment this out to enable debug logging for GIN @@ -26,9 +32,11 @@ func main() { config.LoadFromFile("config.toml") database.Initialize(config.Database) + database.RunMigrations() server := server.Server{ Config: *config, + Debug: *debugArg, } server.StartServer() } diff --git a/backend/app/server/admin_common_api.go b/backend/app/server/admin_common_api.go new file mode 100644 index 0000000..6bfb436 --- /dev/null +++ b/backend/app/server/admin_common_api.go @@ -0,0 +1,88 @@ +package server + +import ( + "codejam.io/database" + "github.com/emicklei/pgtalk/convert" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "net/http" +) + +// VerifyAdminAccess checks if the user has admin access by retrieving the session user account +// and verifying if their role is 'ADMIN'. Appropriate HTTP responses will be set automatically. +// Returns true if the user has admin access, false otherwise. +func (server *Server) VerifyAdminAccess(ctx *gin.Context) bool { + session := sessions.Default(ctx) + userId := session.Get("userId") + if userId == nil { + ctx.Status(http.StatusUnauthorized) + return false + } + + // get the session user account so we can verify they're an admin - TODO, pull from cache + currentUser, err := database.GetUser(convert.StringToUUID(userId.(string))) + if err != nil { + logger.Error("AdminAccessVerified: GetUser for session user error: %v", err) + ctx.Status(http.StatusInternalServerError) + return false + } + + if currentUser.Role != database.Admin { + logger.Error("AdminAccessVerified: unauthorized user: %v", userId) + ctx.Status(http.StatusForbidden) + return false + } + + return true +} + +// UserIsAdmin checks if the user with the given userId has admin access by retrieving the user from the database +// and verifying if their role is 'ADMIN'. Returns true if the user has admin access, false otherwise. +func (server *Server) UserIsAdmin(userId string) (bool, error) { + user, err := database.GetUser(convert.StringToUUID(userId)) + if err != nil { + return false, err + } + + if user.Role == database.Admin { + return true, nil + } + + return false, nil +} + +// VerifyUserNotAdmin checks if the user identified by the given userId is not an admin. This is +// for scenarios when admin accounts should not allow operations to take place on them, such as +// moderation actions. +// Appropriate HTTP responses are set automatically. +// Returns true if the user is not an admin, false otherwise. +func (server *Server) VerifyUserNotAdmin(ctx *gin.Context, userId string) bool { + // Admin accounts cannot be locked, even by other admins + isAdmin, err := server.UserIsAdmin(userId) + if err != nil { + logger.Error("VerifyUserNotAdmin: UserIsAdmin error: %v", err) + ctx.Status(http.StatusInternalServerError) + return false + } + + if isAdmin { + logger.Error("VerifyUserNotAdmin: UserIsAdmin TRUE") + ctx.Status(http.StatusForbidden) + return false + } + + return true +} + +// DeserializeRequest is a helper function to handle deserialization and automatically handling errors. +// Appropriate HTTP responses are set automatically +// Returns true if the request deserialized successfully, false otherwise +func (server *Server) DeserializeRequest(ctx *gin.Context, request any) bool { + err := ctx.ShouldBindJSON(&request) + if err != nil { + logger.Error("%T ShouldBindJSON error: %v", request, err) + ctx.Status(http.StatusBadRequest) + return false + } + return true +} diff --git a/backend/app/server/admin_user_api.go b/backend/app/server/admin_user_api.go new file mode 100644 index 0000000..9496d0e --- /dev/null +++ b/backend/app/server/admin_user_api.go @@ -0,0 +1,122 @@ +package server + +import ( + "codejam.io/database" + "github.com/emicklei/pgtalk/convert" + "github.com/gin-gonic/gin" + "net/http" +) + +func (server *Server) GetAllUsers(ctx *gin.Context) { + if server.VerifyAdminAccess(ctx) { + users, err := database.GetAllUsers() + if err != nil { + logger.Error("Error getting all users: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, users) + } +} + +type PutAccountStatusRequest struct { + AccountStatus string +} + +func (server *Server) PutAccountStatus(ctx *gin.Context) { + userIdParam := ctx.Param("id") + var request PutAccountStatusRequest + if server.VerifyAdminAccess(ctx) && + server.VerifyUserNotAdmin(ctx, userIdParam) && + server.DeserializeRequest(ctx, &request) { + user, err := database.SetAccountStatus(convert.StringToUUID(userIdParam), request.AccountStatus) + if err != nil { + logger.Error("PutAccountStatus: SetAccountStatus error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, user) + } +} + +type PutDisplayNameRequest struct { + DisplayName string +} + +func (server *Server) PutDisplayName(ctx *gin.Context) { + userIdParam := ctx.Param("id") + var request PutDisplayNameRequest + if server.VerifyAdminAccess(ctx) && + server.VerifyUserNotAdmin(ctx, userIdParam) && + server.DeserializeRequest(ctx, &request) { + user, err := database.SetDisplayName(convert.StringToUUID(userIdParam), request.DisplayName) + if err != nil { + logger.Error("SetDisplayName error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, user) + } +} + +type PutDisplayNameLockRequest struct { + Lock bool +} + +func (server *Server) PutDisplayNameLock(ctx *gin.Context) { + userIdParam := ctx.Param("id") + var request PutDisplayNameLockRequest + if server.VerifyAdminAccess(ctx) && + server.VerifyUserNotAdmin(ctx, userIdParam) && + server.DeserializeRequest(ctx, &request) { + user, err := database.SetDisplayNameLock(convert.StringToUUID(userIdParam), request.Lock) + if err != nil { + logger.Error("SetDisplayNameLock error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, user) + } +} + +func (server *Server) PutBan(ctx *gin.Context) { + userIdParam := ctx.Param("id") + if server.VerifyAdminAccess(ctx) && + server.VerifyUserNotAdmin(ctx, userIdParam) { + user, err := database.SetAccountStatus(convert.StringToUUID(userIdParam), "BANNED") + if err != nil { + logger.Error("PutBan: SetAccountStatus error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, user) + } +} + +func (server *Server) PutUnban(ctx *gin.Context) { + userIdParam := ctx.Param("id") + if server.VerifyAdminAccess(ctx) && + server.VerifyUserNotAdmin(ctx, userIdParam) { + user, err := database.SetAccountStatus(convert.StringToUUID(userIdParam), "ACTIVE") + if err != nil { + logger.Error("PutUnban: SetAccountStatus error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, user) + } +} + +func (server *Server) SetupAdminUserRoutes() { + logger.Info("Setting up Admin User routes...") + + group := server.Gin.Group("/admin/user") + { + group.GET("/all", server.GetAllUsers) + group.PUT("/:id/display_name/", server.PutDisplayName) + group.PUT("/:id/display_name_lock/", server.PutDisplayNameLock) + group.PUT("/:id/ban/", server.PutBan) + group.PUT("/:id/unban/", server.PutUnban) + } + +} diff --git a/backend/app/server/event.go b/backend/app/server/event.go index 05347bd..b001bee 100644 --- a/backend/app/server/event.go +++ b/backend/app/server/event.go @@ -2,12 +2,36 @@ package server import ( "codejam.io/database" + "codejam.io/server/models" + "errors" "github.com/emicklei/pgtalk/convert" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5" + "github.com/mrz1836/go-sanitize" "net/http" + "strings" ) +type CodeJamEvent struct { + database.DBEvent + AllowSignups bool +} + +func sanitizeEvent(event *database.DBEvent) { + event.Title = sanitize.Scripts(event.Title) + event.Description = sanitize.Scripts(event.Description) + event.Timeline = sanitize.Scripts(event.Timeline) + event.Rules = sanitize.Scripts(event.Rules) +} + +func validateEvent(event database.DBEvent, response *models.FormResponse) { + // Title is required + if strings.Trim(event.Title, " ") == "" { + response.AddError("Title", "required") + } +} + func (server *Server) GetAllEvents(ctx *gin.Context) { events, err := database.GetEvents() if err == nil { @@ -28,7 +52,28 @@ func (server *Server) GetEvent(ctx *gin.Context) { } } -func (server *Server) CreateEvent(ctx *gin.Context) { +func (server *Server) GetActiveEvent(ctx *gin.Context) { + event, err := database.GetActiveEvent() + + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + // NoRows means no active event exist (or it's still set to PLANNING) + ctx.Status(http.StatusNoContent) + } else { + logger.Error("GetActiveEvent error: %v", err) + ctx.Status(http.StatusInternalServerError) + } + return + } + + var codeJamEvent = CodeJamEvent{ + DBEvent: event, + AllowSignups: server.signupsAllowed(convert.UUIDToString(event.Id)), + } + ctx.JSON(http.StatusOK, codeJamEvent) +} + +func (server *Server) PostEvent(ctx *gin.Context) { session := sessions.Default(ctx) userId := session.Get("userId") if userId != nil { @@ -44,35 +89,78 @@ func (server *Server) CreateEvent(ctx *gin.Context) { } } -func (server *Server) UpdateEvent(ctx *gin.Context) { +func (server *Server) PutEvent(ctx *gin.Context) { session := sessions.Default(ctx) userId := session.Get("userId") if userId != nil { var event database.DBEvent + + // taking submitted data and putting it into an object err := ctx.ShouldBindJSON(&event) if err != nil { logger.Error("UpdateEvent Request ShouldBindJSON error: %v", err) ctx.Status(http.StatusBadRequest) return } + + // Check Authorization + user, err := database.GetUser(convert.StringToUUID(userId.(string))) + if err != nil { + ctx.Status(http.StatusInternalServerError) + return + } + + if user.Role != database.Admin { + logger.Error("PutEvent unauthorized user: %v", userId) + ctx.Status(http.StatusUnauthorized) + return + } + + response := models.NewFormResponse() + + sanitizeEvent(&event) + validateEvent(event, &response) + + // Perform validation + if len(response.Errors) > 0 { + ctx.JSON(http.StatusBadRequest, response) + return + } + + // Update the event in the DB event, err = database.UpdateEvent(event) if err != nil { logger.Error("Error calling database.UpdateEvent: %v", err) ctx.Status(http.StatusInternalServerError) } else { - ctx.JSON(http.StatusOK, event) + logger.Info("User %v updated Event %v", userId, event.Id) + response.Data = event + ctx.JSON(http.StatusOK, response) } } else { + logger.Error("PutEvent Unauthorized: no session") ctx.Status(http.StatusUnauthorized) } } +func (server *Server) GetStatuses(ctx *gin.Context) { + statuses, err := database.GetStatuses() + if err != nil { + logger.Error("Error in GetStatuses: %v", err) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.JSON(http.StatusOK, statuses) + } +} + func (server *Server) SetupEventRoutes() { group := server.Gin.Group("/event") { group.GET("/", server.GetAllEvents) + group.GET("/active", server.GetActiveEvent) group.GET("/:id", server.GetEvent) - group.PUT("/:id", server.UpdateEvent) - group.POST("/", server.CreateEvent) + group.PUT("/:id", server.PutEvent) + group.POST("/", server.PostEvent) + group.GET("/statuses", server.GetStatuses) } } diff --git a/backend/app/server/models/form_response.go b/backend/app/server/models/form_response.go new file mode 100644 index 0000000..83d6fc4 --- /dev/null +++ b/backend/app/server/models/form_response.go @@ -0,0 +1,28 @@ +package models + +type FormError struct { + Field string + Error string +} + +type FormResponse struct { + Errors []FormError + Data any +} + +func NewFormResponse() FormResponse { + var errors []FormError + response := FormResponse{ + Errors: errors, + Data: nil, + } + return response +} + +func (fr *FormResponse) AddError(field string, message string) { + err := FormError{ + Field: field, + Error: message, + } + fr.Errors = append(fr.Errors, err) +} diff --git a/backend/app/server/oauth.go b/backend/app/server/oauth.go index ef88ba7..00e85f2 100644 --- a/backend/app/server/oauth.go +++ b/backend/app/server/oauth.go @@ -1,6 +1,11 @@ package server import ( + "fmt" + "net/http" + "os" + "strings" + "codejam.io/database" "codejam.io/integrations" "github.com/emicklei/pgtalk/convert" @@ -8,13 +13,20 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/oauth2" githubOAuth "golang.org/x/oauth2/github" - "net/http" - "os" - "strings" ) +type StateData struct { + Token string + Redirect string +} + // SetupOAuth initializes the OAuth provider specified in the application config. func (server *Server) SetupOAuth() { + if server.Debug { + logger.Warn("Debug mode is set. No OAuth Providers are set!") + return + } + var endpoint oauth2.Endpoint switch strings.ToLower(server.Config.OAuth.Provider) { @@ -40,13 +52,92 @@ func (server *Server) SetupOAuth() { } func (server *Server) GetOAuthRedirect(ctx *gin.Context) { - url := server.OAuth.AuthCodeURL(ctx.Request.Header.Get("Referer")) + session := sessions.Default(ctx) + token, err := GenerateToken(16) + + if err != nil { + ctx.String(500, "Internal Server Error") + } + + redirect := ctx.Query("redirect") + if redirect == "" { + redirect = "/" + } + + if !strings.HasPrefix(redirect, "/") { + redirect = "/" + } else if strings.HasPrefix(redirect, "/oauth") { + redirect = "/" + } else { + redirect = fmt.Sprintf("/#%s", redirect) + } + + state := StateData{Token: token, Redirect: redirect} + session.Set("state", state) + err = session.Save() + + if err != nil { + logger.Error("Error saving session: %v", err) + } + + if server.Debug { + ctx.Redirect(http.StatusFound, "/oauth/debug-login") + return + } + + url := server.OAuth.AuthCodeURL(token) ctx.Redirect(http.StatusFound, url) } +func (server *Server) GetDebugSession(ctx *gin.Context) { + redir := ctx.Query("state") + + dbUser := database.CreateUser("discord", "0", "DebugCow", "") + session := sessions.Default(ctx) + session.Set("userId", convert.UUIDToString(dbUser.Id)) + session.Set("displayName", dbUser.DisplayName) + + err := session.Save() + + if err != nil { + logger.Error("Error saving debug session: %v", err) + } + + ctx.Redirect(http.StatusFound, redir) +} + func (server *Server) GetOAuthCallback(ctx *gin.Context) { authCode := ctx.Query("code") - redir := ctx.Query("state") + stateCode := ctx.Query("state") + fmt.Println(stateCode) + + if len(stateCode) == 0 { + ctx.String(400, "Bad Request: Missing State Value.") + return + } + + session := sessions.Default(ctx) + stateI := session.Get("state") + + session.Clear() + session.Save() + + var stateData *StateData + // give the stateI a defined type/struct of stateData instead of interface{}/any: + stateData, ok := stateI.(*StateData) + if !ok { + ctx.String(400, "Bad Request: Invalid State Data.") + return + } + + if stateData.Token != stateCode { + logger.Error("Invalid state token provided.") + ctx.String(400, "Bad Request: Invalid State Code Provided.") + return + } + + redir := stateData.Redirect + token, err := server.OAuth.Exchange(oauth2.NoContext, authCode) if err != nil { // todo - can any of these be handled? @@ -57,8 +148,7 @@ func (server *Server) GetOAuthCallback(ctx *gin.Context) { integrationName := strings.ToLower(server.Config.OAuth.Provider) providerUser := integrations.GetUser(integrationName, token.AccessToken) if providerUser != nil { - dbUser := database.CreateUser(integrationName, providerUser.UserId, providerUser.DisplayName) - session := sessions.Default(ctx) + dbUser := database.CreateUser(integrationName, providerUser.UserId, providerUser.ServiceUserName, providerUser.AvatarId) session.Set("userId", convert.UUIDToString(dbUser.Id)) session.Set("displayName", dbUser.DisplayName) err = session.Save() @@ -80,5 +170,6 @@ func (server *Server) SetupOAuthRoutes() { { group.GET("/redirect", server.GetOAuthRedirect) group.GET("/callback", server.GetOAuthCallback) + group.GET("/debug-login", server.GetDebugSession) } } diff --git a/backend/app/server/server.go b/backend/app/server/server.go index 4b1e1fa..1536db1 100644 --- a/backend/app/server/server.go +++ b/backend/app/server/server.go @@ -3,6 +3,7 @@ package server import ( "codejam.io/config" "codejam.io/logging" + "encoding/gob" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/redis" "github.com/gin-gonic/gin" @@ -18,6 +19,7 @@ type Server struct { Config config.Config OAuth *oauth2.Config Gin *gin.Engine + Debug bool } func (server *Server) SetupSessionStore() { @@ -46,6 +48,8 @@ func (server *Server) SetupSessionStore() { func (server *Server) StartServer() { logger.Info("Starting server...") + gob.Register(&StateData{}) + server.Gin = gin.Default() server.SetupSessionStore() @@ -55,6 +59,9 @@ func (server *Server) StartServer() { server.SetupOAuthRoutes() server.SetupUserRoutes() server.SetupEventRoutes() + server.SetupTeamRoutes() + server.SetupAdminUserRoutes() + server.SetupStaticRoutes() // Start the server... diff --git a/backend/app/server/static.go b/backend/app/server/static.go index 197cfa3..9b4a6ff 100644 --- a/backend/app/server/static.go +++ b/backend/app/server/static.go @@ -2,7 +2,6 @@ package server import ( "embed" - _ "embed" "github.com/gin-gonic/gin" "mime" "net/http" diff --git a/backend/app/server/teams.go b/backend/app/server/teams.go new file mode 100644 index 0000000..df44c66 --- /dev/null +++ b/backend/app/server/teams.go @@ -0,0 +1,409 @@ +package server + +import ( + "fmt" + "net/http" + + "codejam.io/database" + "github.com/emicklei/pgtalk/convert" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgtype" + + "crypto/md5" + "crypto/rand" + "encoding/hex" + "math" + "math/big" +) + +type CreateTeamRequest struct { + // sent from UI, to be processed in the server into a DBTeam structure + // referenced in server/teams.go/CreateTeam line 53 + EventId string + Name string + Visibility string + Availability string + Description string + Technologies string + Timezone string +} + +type GetTeamResponse struct { + Team *database.DBTeam + Event *database.DBEvent + TeamMembers *[]database.DBTeamMemberInfo // array(slice) of a struct +} + +type InvitePayload struct { + TeamId string `json:"teamId"` + InviteCode string `json:"inviteCode"` +} + +type JoinPayload struct { + TeamId string `json:"teamId"` +} + +type MemberPayload struct { + TeamId string `json:"teamId"` + MemberId string `json:"memberId"` +} + +func MD5HashCode(teamName string) (string, error) { + randNum, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + return "Md5 hash error", err + } + hash := md5.Sum([]byte(teamName + "." + randNum.String())) + return hex.EncodeToString(hash[:7]), nil +} + +func (server *Server) signupsAllowed(eventId string) bool { + statusCode, err := database.GetEventStatusCode(convert.StringToUUID(eventId)) + if err != nil { + logger.Error("Error in GetEventStatusCode: %v+", err) + return false + } + + if statusCode == "SIGNUP" || statusCode == "STARTED" { + return true + } else { + return false + } +} + +// for path teams/browse +func (server *Server) GetAllTeams(ctx *gin.Context) { + // what if there's no session => no user id + teams, err := database.GetTeams() + if err == nil { + ctx.JSON(http.StatusOK, teams) + } else { + fmt.Println("ERROR: ", err) + ctx.Status(http.StatusInternalServerError) + return + } +} + +func (server *Server) GetUserTeams(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + + if userId == nil { + ctx.Status(http.StatusNotFound) + return + } + strUserId := userId.(string) + + teams, err := database.GetUserTeams(convert.StringToUUID(strUserId)) + if err != nil { + ctx.Status(http.StatusInternalServerError) + return + } + + ctx.JSON(http.StatusOK, teams) +} + +// purpose is to construct the DBTeamMemberInfo +func (server *Server) GetTeamInfo(id pgtype.UUID) (*GetTeamResponse, error) { + + var teamResponse GetTeamResponse + var team database.DBTeam + var event database.DBEvent + var teamMembers *[]database.DBTeamMemberInfo //user info based on teamId + // fmt.Println("========id: ", id) prints: {[204 69 126 62 33 10 77 93 131 216 8 153 66 109 252 147] true} + team, err := database.GetTeam(id) + if err != nil { + logger.Error("failed to get team: %v", err) + return nil, err + } + + event, err = database.GetEvent(team.EventId) + if err != nil { + logger.Error("failed to get event: %v", err) + return nil, err + } + + teamMembers, err = database.GetMembersByTeamId(team.Id) + if err != nil { + logger.Error("failed to get event: %v", err) + return nil, err + } + + // attach all 3 structures to GetTeamResponse --> nested structs turn into nested JSON (with ctx.JSON) + teamResponse.Team = &team + teamResponse.Event = &event + teamResponse.TeamMembers = teamMembers + + return &teamResponse, nil +} + +func (server *Server) sendTeamInfo(ctx *gin.Context) { + id := convert.StringToUUID(ctx.Param("id")) + + teamResponse, err := server.GetTeamInfo(id) + + if err != nil { + ctx.Status(http.StatusBadRequest) + } + fmt.Printf("%+v\n", teamResponse.TeamMembers) + ctx.JSON(http.StatusOK, teamResponse) +} + +func (server *Server) GetTeamInfoByInviteCode(ctx *gin.Context) { + inviteCode := ctx.Param("invitecode") + + var teamResponse GetTeamResponse + var team database.DBTeam + var event database.DBEvent + var teamMembers *[]database.DBTeamMemberInfo //user info based on teamId + + team, err := database.GetTeamByInvite(inviteCode) + if err != nil { + logger.Error("failed to get team: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get team: %v", err)}) + return + } else { + fmt.Println("TEST if GetTeamByInvite was SUCCESS: ", err, " + ", team) + } + + event, err = database.GetEvent(team.EventId) + if err != nil { + logger.Error("failed to get event: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get event: %v", err)}) + return + } + + teamMembers, err = database.GetMembersByTeamId(team.Id) + if err != nil { + logger.Error("failed to get event: %v", err) + ctx.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get members: %v", err)}) + return + } + + // attach all 3 structures to GetTeamResponse --> nested structs turn into nested JSON (with ctx.JSON) + teamResponse.Team = &team + teamResponse.Event = &event + teamResponse.TeamMembers = teamMembers + + fmt.Println(teamResponse) + ctx.JSON(http.StatusOK, teamResponse) +} + +func (server *Server) CreateTeam(ctx *gin.Context) { + // ctx of *gin.Context has HTTP request info. + // Step 4: Post Team Data API (TWO PARTS 1) create team 2) add team members) + session := sessions.Default(ctx) + userId := session.Get("userId") + if userId == nil { + ctx.Status(http.StatusUnauthorized) + return + } + strUserId := userId.(string) + + var team database.DBTeam + var teamReq CreateTeamRequest + + err := ctx.ShouldBindJSON(&teamReq) + if err != nil { + logger.Error("CreateTeam Request ShouldBindJSON error: %v", err) + ctx.Status(http.StatusBadRequest) + return + } + + if !server.signupsAllowed(teamReq.EventId) { + ctx.Status(http.StatusForbidden) + return + } + + // CONVERT teamReq to team + team.EventId = convert.StringToUUID(teamReq.EventId) + team.Name = teamReq.Name + team.Availability = teamReq.Availability + team.Description = teamReq.Description + team.Visibility = teamReq.Visibility + team.Technologies = teamReq.Technologies + team.Timezone = teamReq.Timezone + + md5code, err := MD5HashCode(team.Name) + if err != nil { + logger.Error("Error - Md5HashCode failed", err) + ctx.Status(http.StatusInternalServerError) + return + } + team.InviteCode = md5code + + fmt.Printf("%+v", team) + + // INSERTS TEAM into DB + // PART 1/2 DONE + teamUUID, err := database.CreateTeam(team) + if err != nil { + logger.Error("Error trying to CreateTeam(team)") + ctx.Status(http.StatusBadRequest) + return + } + fmt.Println(convert.StringToUUID(strUserId), teamUUID) + + // PART 2/2 DONE + // construct TeamMember + _, err = database.AddTeamMember(convert.StringToUUID(strUserId), teamUUID, "owner") + + if err == nil { + ctx.JSON(http.StatusCreated, map[string]pgtype.UUID{ + "id": teamUUID, + }) + } else { + fmt.Println(err) + logger.Error("AddTeamMember error: %v for user %s", err, strUserId) + ctx.Status(http.StatusInternalServerError) + return + } +} + +func (server *Server) UpdateTeam(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + + if userId != nil { + var team database.DBTeam + err := ctx.ShouldBindJSON(&team) // "message incoming data to this struct" + + if err != nil { + logger.Error("UpdateEvent Request ShouldBindJSON error: %v", err) + ctx.Status(http.StatusBadRequest) + return + } + + updatedTeam, err := database.UpdateTeam(team) + if err != nil { + logger.Error("Error calling database.UpdateTeam: %v", err) + ctx.Status(http.StatusInternalServerError) + } else { + ctx.JSON(http.StatusOK, updatedTeam) + } + } else { + ctx.Status(http.StatusUnauthorized) + } +} + +func (server *Server) RemoveTeamMember(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + if userId == nil { + ctx.Status(http.StatusUnauthorized) + return + } + + var MemberPayload MemberPayload + + if err := ctx.ShouldBindJSON(&MemberPayload); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + uuidTeamId := convert.StringToUUID(MemberPayload.TeamId) + uuidMemberId := convert.StringToUUID(MemberPayload.MemberId) + + removedMember, err := database.RemoveTeamMember(uuidTeamId, uuidMemberId) + if err != nil { + fmt.Println("Error removing team member: ", err) + } + + strTeamId := convert.UUIDToString(removedMember.TeamId) + + ctx.JSON(http.StatusOK, strTeamId) +} + +func (server *Server) MemberJoin(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + + if userId == nil { + ctx.Status(http.StatusUnauthorized) + return + } + + strUserId := userId.(string) + uuidUserId := convert.StringToUUID(strUserId) + + var teamId JoinPayload + + if err := ctx.ShouldBindJSON(&teamId); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // teamId prints: {cc457e3e-210a-4d5d-83d8-0899426dfc93} + uuidTeamId := convert.StringToUUID(teamId.TeamId) + teamInfo, err := server.GetTeamInfo(uuidTeamId) + + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // verifies team is public + var isPublic string = teamInfo.Team.Visibility + if isPublic == "private" { + ctx.Status(http.StatusForbidden) + return + } + _, err = database.AddTeamMember(uuidUserId, uuidTeamId, "member") + if err != nil { + ctx.JSON(http.StatusConflict, err) + return + } + strTeamId := convert.UUIDToString(teamInfo.Team.Id) + ctx.JSON(http.StatusOK, strTeamId) +} + +func (server *Server) MemberInvite(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + + var payload InvitePayload + // bind the message context to the structure, and do an error check + if err := ctx.ShouldBindJSON(&payload); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + inviteCode := payload.InviteCode + teamId := payload.TeamId + uuidTeamId := convert.StringToUUID(teamId) + strUserId := userId.(string) + uuidUserId := convert.StringToUUID(strUserId) + + // I want to check if the inviteCode matches the teams invite code + dbInviteCode, err := database.GetTeamInviteCode(uuidTeamId) + if err != nil { + ctx.JSON(http.StatusBadRequest, inviteCode) + } + fmt.Println("server dbInviteCode: ", dbInviteCode) + if inviteCode != dbInviteCode.InviteCode { + fmt.Println("invalid request") + } else { + _, err = database.AddTeamMember(uuidUserId, uuidTeamId, "member") + if err != nil { + ctx.JSON(http.StatusConflict, err) + return + } + } +} + +func (server *Server) SetupTeamRoutes() { + group := server.Gin.Group("/team") + { + group.POST("/", server.CreateTeam) + group.POST("/join", server.MemberJoin) + group.POST("/:invitecode", server.MemberInvite) + + group.GET("/:id", server.sendTeamInfo) + group.GET("/invite/:invitecode", server.GetTeamInfoByInviteCode) + + group.PUT("/edit/:teamid", server.UpdateTeam) // for admin to remove people + group.DELETE("/:teamid/member/:memberid", server.RemoveTeamMember) + } + server.Gin.GET("/teams", server.GetUserTeams) // I think this works rofl + server.Gin.GET("/teams/browse", server.GetAllTeams) +} diff --git a/backend/app/server/user.go b/backend/app/server/user.go deleted file mode 100644 index dc634f7..0000000 --- a/backend/app/server/user.go +++ /dev/null @@ -1,45 +0,0 @@ -package server - -import ( - "codejam.io/database" - "github.com/emicklei/pgtalk/convert" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "net/http" -) - -func (server *Server) GetUser(ctx *gin.Context) { - session := sessions.Default(ctx) - userId := session.Get("userId") - if userId != nil { - dbUser := database.GetUser(convert.StringToUUID(userId.(string))) - ctx.JSON(http.StatusOK, dbUser) - return - } - ctx.Status(http.StatusUnauthorized) -} - -// Logout is a GET route for logging out a user. -// This involved clearing the session cookie, clearing/deleting the entry from the session store -func (server *Server) Logout(ctx *gin.Context) { - session := sessions.Default(ctx) - session.Clear() - err := session.Save() - if err != nil { - logger.Error("Logout: error saving session: %v", err) - } - - // Have to manually do this, not sure why the session middleware doesn't handle this... - ctx.SetCookie(SessionCookieName, "", -1, "/", "", false, false) - ctx.Redirect(http.StatusFound, ctx.Request.Header.Get("Referer")) -} - -func (server *Server) SetupUserRoutes() { - logger.Info("Setting up User routes...") - - group := server.Gin.Group("/user") - { - group.GET("/", server.GetUser) - group.GET("/logout", server.Logout) - } -} diff --git a/backend/app/server/users_api.go b/backend/app/server/users_api.go new file mode 100644 index 0000000..54d6fe4 --- /dev/null +++ b/backend/app/server/users_api.go @@ -0,0 +1,117 @@ +package server + +import ( + "codejam.io/database" + "codejam.io/server/models" + "github.com/emicklei/pgtalk/convert" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "net/http" + "strings" +) + +func validateDisplayName(displayName string, response *models.FormResponse) { + if displayName == "" { + response.AddError("DisplayName", "required") + } +} + +func (server *Server) GetUser(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + if userId != nil { + dbUser, err := database.GetUser(convert.StringToUUID(userId.(string))) + if err != nil { + logger.Error("GetUser: %v, %v", userId, err) + ctx.Status(http.StatusInternalServerError) + return + } + ctx.JSON(http.StatusOK, dbUser) + return + } + ctx.Status(http.StatusUnauthorized) +} + +type PutProfileRequest struct { + DisplayName string +} + +func (server *Server) PutProfile(ctx *gin.Context) { + session := sessions.Default(ctx) + userId := session.Get("userId") + if userId != nil { + user, err := database.GetUser(convert.StringToUUID(userId.(string))) + if err != nil { + logger.Error("PutProfile: GetUser error: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } + + // admin display name lock prevents a user from changing it + if user.LockDisplayName { + logger.Info("DisplayName Locked") + ctx.Status(http.StatusForbidden) + return + } + + var request PutProfileRequest + err = ctx.ShouldBindJSON(&request) + if err != nil { + ctx.Status(http.StatusBadRequest) + return + } + + response := models.NewFormResponse() + request.DisplayName = strings.Trim(request.DisplayName, " ") + + // Perform validation + validateDisplayName(request.DisplayName, &response) + if len(response.Errors) > 0 { + logger.Error("Validation Error: %v+", user) + ctx.JSON(http.StatusBadRequest, response) + return + } + + user.DisplayName = request.DisplayName + _, err = database.UpdateUser(user) + if err != nil { + logger.Error("Error calling database.UpdateEvent: %v", err) + ctx.Status(http.StatusInternalServerError) + return + } else { + response.Data = user + ctx.JSON(http.StatusOK, response) + } + + } else { + logger.Error("PutUser Unauthorized: no session") + ctx.Status(http.StatusUnauthorized) + } +} + +// Logout is a GET route for logging out a user. +// This involved clearing the session cookie, clearing/deleting the entry from the session store +func (server *Server) Logout(ctx *gin.Context) { + session := sessions.Default(ctx) + session.Clear() + err := session.Save() + if err != nil { + logger.Error("Logout: error saving session: %v", err) + } + + // Have to manually do this, not sure why the session middleware doesn't handle this... + ctx.SetCookie(SessionCookieName, "", -1, "/", "", false, false) + ctx.Redirect(http.StatusFound, ctx.Request.Header.Get("Referer")) +} + +func (server *Server) SetupUserRoutes() { + logger.Info("Setting up User routes...") + + group := server.Gin.Group("/user") + { + group.GET("/", server.GetUser) + group.PUT("/profile", server.PutProfile) + group.GET("/logout", server.Logout) + } + +} diff --git a/backend/app/server/utils.go b/backend/app/server/utils.go new file mode 100644 index 0000000..147ed60 --- /dev/null +++ b/backend/app/server/utils.go @@ -0,0 +1,17 @@ +package server + +import ( + "crypto/rand" + "encoding/base64" +) + +func GenerateToken(length uint8) (string, error) { + token := make([]byte, length) + + _, err := rand.Read(token) + if err != nil { + return "", err + } + + return base64.URLEncoding.EncodeToString(token), nil +} diff --git a/backend/database/schema.sql b/backend/database/schema.sql index 6f84fec..cdfdba9 100644 --- a/backend/database/schema.sql +++ b/backend/database/schema.sql @@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS events ( status_id integer, title TEXT NOT NULL, description TEXT NOT NULL, + timeline TEXT NOT NULL, rules TEXT NOT NULL, organizer_user_id UUID NOT NULL, max_teams integer default -1, diff --git a/build.sh b/build.sh index 2808635..df0980d 100755 --- a/build.sh +++ b/build.sh @@ -1,9 +1,41 @@ #!/bin/bash +# set -x # enable debug output +set -e # exit on error +trap ErrorHandler ERR + +function ErrorHandler { + echo -e "\e[91mError Occurred\e[0m" + exit 1 +} + +echo "Building Frontend" cd html || return npm install npm run build mkdir -p ../backend/app/server/static_files +rm -rf ../backend/app/server/static_files/* cp -r dist/* ../backend/app/server/static_files/ +cp -r static/ ../backend/app/server/static_files/ + +echo "Building Backend" + +cd ../backend/app || return +go build + +if [ -e codejam.io ]; then + if [ -e /opt/codejam/staging/codejam.io ]; then + rm /opt/codejam/staging/codejam.io + else + echo "WARNING: Existing deployment not found" + fi + + cp codejam.io /opt/codejam/staging/codejam.io +else + echo -e "\e[92mBackend Build Failed: missing binary file found\e[0m" + exit 1 +fi + +echo -e "\e[32mSuccess\e[0m" diff --git a/docker-compose.override.yaml b/docker-compose.override.yaml new file mode 100644 index 0000000..2caf1df --- /dev/null +++ b/docker-compose.override.yaml @@ -0,0 +1,10 @@ +version: '3.9' + +services: + database: + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: codejam + volumes: + - './docker/postgres/:/var/lib/postgresql/data' diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..ba40536 --- /dev/null +++ b/docker-compose.yaml @@ -0,0 +1,19 @@ +version: '3.9' + +services: + server: + image: 'timeenjoyed/codejam:latest' + container_name: codejam_backend + build: . + volumes: + - ./backend/app/config.toml:/app/config.toml + ports: + - '8080:8080' + + database: + image: 'library/postgres:16.3-alpine3.20' + container_name: codejam_database + + redis: + image: 'library/redis:7.0.15-alpine3.20' + container_name: codejam_redis diff --git a/html/index.html b/html/index.html index 0745c37..3157f02 100644 --- a/html/index.html +++ b/html/index.html @@ -3,6 +3,13 @@
+ + + + + + ++ replace this text with goals content +
++ The European Union’s General Data Protection Regulation (G.D.P.R.) goes into effect on May 25 + and is meant to ensure a common set of data rights in the European Union. It requires + organizations to notify users as soon as possible of high-risk data breaches that could + personally affect them. +
++ With less than a month to go before the European Union enacts new consumer privacy laws for its + citizens, companies around the world are updating their terms of service agreements to comply. +
++ The European Union’s General Data Protection Regulation (G.D.P.R.) goes into effect on May 25 + and is meant to ensure a common set of data rights in the European Union. It requires + organizations to notify users as soon as possible of high-risk data breaches that could + personally affect them. +
++ With less than a month to go before the European Union enacts new consumer privacy laws for its + citizens, companies around the world are updating their terms of service agreements to comply. +
++ The European Union’s General Data Protection Regulation (G.D.P.R.) goes into effect on May 25 + and is meant to ensure a common set of data rights in the European Union. It requires + organizations to notify users as soon as possible of high-risk data breaches that could + personally affect them. +
+In order to participate in the codejam, first step is to join a team. You can scroll through teams on the right, or you can create a new team.
+stuff stuff order to participate in the codejam, first order to participate in the codejam, first order to participate in the codejam, first order to participate in the codejam, first
+ +Logged In: {loggedIn}
+
+ {@html $activeEventStore?.Timeline}
+ {@html $activeEventStore?.Description}
+ {@html $activeEventStore?.Rules}
+ Loading...
+ {:else if error} +{error}
+ {:else} +Click below to join {teamMembers[0]?.DisplayName}'s team:
+ + + {:else if isUserInTeam(teamMembers)} +Hi {$userStore?.DisplayName}, Looks like you're already in this team! Go here to view it.
+ + {/if} +Must be logged in to join a team.
+ +Browse other teams looking for members like you!
+ ++ By creating a team, you're in control of the team page. Decide whether anyone can join or + just people with the invite code. +
+ +For those who want to work alone. You can change your mind later.
+ +
+ Login with Discord