diff --git a/pkg/data/data.go b/pkg/data/data.go index 1c1bd1c..d5bcce7 100644 --- a/pkg/data/data.go +++ b/pkg/data/data.go @@ -27,6 +27,21 @@ type Job struct { PublishedAt time.Time `db:"published_at"` } +type Role struct { + ID string `db:"id"` + Name string `db:"name"` + Email string `db:"email"` + Phone sql.NullString `db:"phone"` + Role string `db:"role"` + Resume string `db:"resume"` + Linkedin sql.NullString `db:"linkedin"` + Website sql.NullString `db:"website"` + Github sql.NullString `db:"github"` + CompLow sql.NullString `db:"comp_low"` + CompHigh sql.NullString `db:"comp_high"` + PublishedAt time.Time `db:"published_at"` +} + const ( ErrNoPosition = "Must provide a Position" ErrNoOrganization = "Must provide a Organization" @@ -34,6 +49,9 @@ const ( ErrInvalidUrl = "Must provide a valid Url" ErrInvalidEmail = "Must provide a valid Email" ErrNoUrlOrDescription = "Must provide either a Url or a Description" + ErrNoName = "Must provide a Name" + ErrNoRole = "Must provide a Role" + ErrNoResume = "Must provide a Resume" ) func (job *Job) Update(newParams NewJob) { @@ -47,6 +65,30 @@ func (job *Job) Update(newParams NewJob) { job.Description.Valid = newParams.Description != "" } +func (role *Role) Update(newParams NewRole) { + role.Name = newParams.Name + role.Role = newParams.Role + role.Resume = newParams.Resume + + role.Phone.String = newParams.Phone + role.Phone.Valid = newParams.Phone != "" + + role.Linkedin.String = newParams.Linkedin + role.Linkedin.Valid = newParams.Linkedin != "" + + role.Website.String = newParams.Website + role.Website.Valid = newParams.Website != "" + + role.Github.String = newParams.Github + role.Github.Valid = newParams.Github != "" + + role.CompLow.String = newParams.CompLow + role.CompLow.Valid = newParams.CompLow != "" + + role.CompHigh.String = newParams.CompHigh + role.CompHigh.Valid = newParams.CompHigh != "" +} + func (job *Job) RenderDescription() (string, error) { if !job.Description.Valid { return "", nil @@ -65,7 +107,27 @@ func (job *Job) RenderDescription() (string, error) { var b bytes.Buffer if err := markdown.Convert([]byte(job.Description.String), &b); err != nil { - return "", fmt.Errorf("failed to convert job descroption to markdown (job id: %s): %w", job.ID, err) + return "", fmt.Errorf("failed to convert job description to markdown (job id: %s): %w", job.ID, err) + } + + return b.String(), nil +} + +func (role *Role) RenderResume() (string, error) { + markdown := goldmark.New( + goldmark.WithExtensions( + extension.NewLinkify( + extension.WithLinkifyAllowedProtocols([][]byte{ + []byte("http:"), + []byte("https:"), + }), + ), + ), + ) + + var b bytes.Buffer + if err := markdown.Convert([]byte(role.Resume), &b); err != nil { + return "", fmt.Errorf("failed to convert resume to markdown (role id: %s): %w", role.ID, err) } return b.String(), nil @@ -78,6 +140,13 @@ func (job *Job) Save(db *sqlx.DB) (sql.Result, error) { ) } +func (role *Role) Save(db *sqlx.DB) (sql.Result, error) { + return db.Exec( + "UPDATE roles SET name = $1, phone = $2, role = $3, resume = $4, linkedin = $5, website = $6, github = $7, comp_low = $8, comp_high = $9 WHERE id = $10", + role.Name, role.Phone, role.Role, role.Resume, role.Linkedin, role.Website, role.Github, role.CompLow, role.CompHigh, role.ID, + ) +} + func (job *Job) AuthSignature(secret string) string { input := fmt.Sprintf( "%s:%s:%s", @@ -92,6 +161,21 @@ func (job *Job) AuthSignature(secret string) string { return base64.URLEncoding.EncodeToString(hash.Sum(nil)) } +func (role *Role) AuthSignature(secret string) string { + input := fmt.Sprintf( + "%s:%s:%s:%s", + role.ID, + role.Email, + role.PublishedAt.String(), + secret, + ) + + hash := hmac.New(sha256.New, []byte(secret)) + hash.Write([]byte(input)) + + return base64.URLEncoding.EncodeToString(hash.Sum(nil)) +} + func GetAllJobs(db *sqlx.DB) ([]Job, error) { var jobs []Job @@ -114,6 +198,28 @@ func GetJob(id string, db *sqlx.DB) (Job, error) { return job, nil } +func GetAllRoles(db *sqlx.DB) ([]Role, error) { + var roles []Role + + err := db.Select(&roles, "SELECT * FROM roles ORDER BY published_at DESC") + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return roles, err + } + + return roles, nil +} + +func GetRole(id string, db *sqlx.DB) (Role, error) { + var role Role + + err := db.Get(&role, "SELECT * FROM roles WHERE id = $1", id) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return role, err + } + + return role, nil +} + type NewJob struct { Position string `form:"position"` Organization string `form:"organization"` @@ -153,6 +259,64 @@ func (newJob *NewJob) Validate(update bool) map[string]string { return errs } +type NewRole struct { + Name string `form:"name"` + Email string `form:"email"` + Phone string `form:"phone"` + Role string `form:"role"` + Resume string `form:"resume"` + Linkedin string `form:"linkedin"` + Website string `form:"website"` + Github string `form:"github"` + CompLow string `form:"complow"` + CompHigh string `form:"comphigh"` +} + +func (newJob *NewRole) Validate(update bool) map[string]string { + errs := make(map[string]string) + + if newJob.Name == "" { + errs["name"] = ErrNoName + } + + if !update { + if newJob.Email == "" { + errs["email"] = ErrNoEmail + } else if _, err := mail.ParseAddress(newJob.Email); err != nil { + // TODO: Maybe do more than just validate email format? + errs["email"] = ErrInvalidEmail + } + } + + if newJob.Role == "" { + errs["role"] = ErrNoRole + } + + if newJob.Resume == "" { + errs["resume"] = ErrNoResume + } + + if newJob.Linkedin != "" { + if _, err := url.ParseRequestURI(newJob.Linkedin); err != nil { + errs["linkedin"] = ErrInvalidUrl + } + } + + if newJob.Website != "" { + if _, err := url.ParseRequestURI(newJob.Website); err != nil { + errs["website"] = ErrInvalidUrl + } + } + + if newJob.Github != "" { + if _, err := url.ParseRequestURI(newJob.Github); err != nil { + errs["github"] = ErrInvalidUrl + } + } + + return errs +} + func (newJob *NewJob) SaveToDB(db *sqlx.DB) (Job, error) { query := `INSERT INTO jobs (position, organization, url, description, email) @@ -179,3 +343,47 @@ func (newJob *NewJob) SaveToDB(db *sqlx.DB) (Job, error) { } return job, nil } + +func (newRole *NewRole) SaveToDB(db *sqlx.DB) (Role, error) { + query := `INSERT INTO roles + (name, email, phone, role, resume, linkedin, website, github, comp_low, comp_high) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING *` + + params := []interface{}{ + newRole.Name, + newRole.Email, + sql.NullString{ + String: newRole.Phone, + Valid: newRole.Phone != "", + }, + newRole.Role, + newRole.Resume, + sql.NullString{ + String: newRole.Linkedin, + Valid: newRole.Linkedin != "", + }, + sql.NullString{ + String: newRole.Website, + Valid: newRole.Website != "", + }, + sql.NullString{ + String: newRole.Github, + Valid: newRole.Github != "", + }, + sql.NullString{ + String: newRole.CompLow, + Valid: newRole.CompLow != "", + }, + sql.NullString{ + String: newRole.CompHigh, + Valid: newRole.CompHigh != "", + }, + } + + var role Role + if err := db.QueryRowx(query, params...).StructScan(&role); err != nil { + return role, err + } + return role, nil +} diff --git a/pkg/data/data_test.go b/pkg/data/data_test.go index 57864cd..fb620c9 100644 --- a/pkg/data/data_test.go +++ b/pkg/data/data_test.go @@ -4,7 +4,7 @@ import ( "testing" ) -func TestValidate(t *testing.T) { +func TestJobValidate(t *testing.T) { testJob := &NewJob{ Position: "test position", Organization: "test org", @@ -38,3 +38,72 @@ func TestValidate(t *testing.T) { t.Error("bad email, should show an error - result was=", result["email"]) } } + +func TestRoleValidate(t *testing.T) { + testRole := &NewRole{ + Name: "test testington", + Email: "test@foobar.com", + Phone: "316-555-5555", + Role: "any", + Resume: "#wow\n\nhire this person", + Linkedin: "https://linkedin.com/in/testtestingtonsupreme", + Website: "https://www.example.com", + Github: "https://www.github.com/example", + CompLow: "10,000", + CompHigh: "1,000,000", + } + + // test valid name + result := testRole.Validate(false) + if result["name"] == "Must provide a Name" { + t.Error("valid name, should have no error - result was=", result["name"]) + } + + // test valid email format + if result["email"] == "Must provide a valid Email" { + t.Error("valid email, should have no error - result was=", result["email"]) + } + + // test valid role + if result["role"] == "Must provide a Role" { + t.Error("valid email, should have no error - result was=", result["role"]) + } + + // test valid role + if result["resume"] == "Must provide a Resume" { + t.Error("valid email, should have no error - result was=", result["resume"]) + } + + // test valid urls + if result["linkedin"] == "Must provide a valid Url" { + t.Error("valid url, should have no error - result was=", result["linkedin"]) + } + if result["website"] == "Must provide a valid Url" { + t.Error("valid url, should have no error - result was=", result["website"]) + } + if result["github"] == "Must provide a valid Url" { + t.Error("valid url, should have no error - result was=", result["github"]) + } + + // test bad url format + testRole.Linkedin = "https//test.com/" + testRole.Website = "https//test.com/" + testRole.Github = "https//test.com/" + result = testRole.Validate(false) + if result["linkedin"] != "Must provide a valid Url" { + t.Error("bad url, should show an error - result was=", result["linkedin"]) + } + if result["website"] != "Must provide a valid Url" { + t.Error("bad url, should show an error - result was=", result["website"]) + } + if result["github"] != "Must provide a valid Url" { + t.Error("bad url, should show an error - result was=", result["github"]) + } + + // test bad email format + testRole.Email = "testtest.com" + result = testRole.Validate(false) + if result["email"] != "Must provide a valid Email" { + t.Error("bad email, should show an error - result was=", result["email"]) + } +} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index c1e401c..efc8083 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -30,8 +30,16 @@ func (ctrl *Controller) Index(ctx *gin.Context) { return } + roles, err := data.GetAllRoles(ctrl.DB) + if err != nil { + log.Println(fmt.Errorf("Index failed to getAllRoles: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + ctx.HTML(200, "index", addFlash(ctx, gin.H{ "jobs": jobs, + "roles": roles, "noJobs": len(jobs) == 0, })) } @@ -123,7 +131,7 @@ func (ctrl *Controller) CreateJob(ctx *gin.Context) { } if ctrl.SlackService != nil { - if err = ctrl.SlackService.PostToSlack(job); err != nil { + if err = ctrl.SlackService.PostJobToSlack(job); err != nil { log.Println(fmt.Errorf("failed to postToSlack: %w", err)) // continuing... } @@ -205,6 +213,164 @@ func (ctrl *Controller) ViewJob(ctx *gin.Context) { ctx.HTML(200, "view", gin.H{"job": job, "description": template.HTML(description)}) } +func (ctrl *Controller) NewRole(ctx *gin.Context) { + session := sessions.Default(ctx) + + fields := []string{"name", "email", "phone", "role", "resume", "linkedin", "website", "github", "complow", "comphigh"} + + tVars := gin.H{} + for _, k := range fields { + f := fmt.Sprintf("%s_err", k) + tVars[f] = session.Flashes(f) + } + + ctx.HTML(200, "newrole", addFlash(ctx, tVars)) +} + +func (ctrl *Controller) CreateRole(ctx *gin.Context) { + var newRoleInput data.NewRole + if err := ctx.Bind(&newRoleInput); err != nil { + log.Println(fmt.Errorf("failed to ctx.Bind: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + session := sessions.Default(ctx) + defer func() { + if err := session.Save(); err != nil { + log.Println(fmt.Errorf("CreateRole failed to session.Save: %w", err)) + } + }() + + if errs := newRoleInput.Validate(false); len(errs) != 0 { + for k, v := range errs { + session.AddFlash(v, fmt.Sprintf("%s_err", k)) + } + + ctx.Redirect(302, "/newrole") + return + } + + role, err := newRoleInput.SaveToDB(ctrl.DB) + if err != nil { + log.Println(fmt.Errorf("failed to save role to db: %w", err)) + session.AddFlash("Error creating role") + ctx.Redirect(302, "/newrole") + return + } + + if ctrl.EmailService != nil { + // TODO: make this a nicer html template? + message := fmt.Sprintf( + "Your job search has begun!\n\nUse this link to edit the posting", + SignedRoleRoute(role, ctrl.Config), + ) + err = ctrl.EmailService.SendEmail(newRoleInput.Email, "Post Created!", message) + if err != nil { + log.Println(fmt.Errorf("failed to sendEmail: %w", err)) + // continuing... + } + } + + if ctrl.SlackService != nil { + if err = ctrl.SlackService.PostRoleToSlack(role); err != nil { + log.Println(fmt.Errorf("failed to postToSlack: %w", err)) + // continuing... + } + } + + session.AddFlash("Post created!") + ctx.Redirect(302, "/") +} + +func (ctrl *Controller) ViewRole(ctx *gin.Context) { + id := ctx.Param("id") + role, err := data.GetRole(id, ctrl.DB) + if err != nil { + log.Println(fmt.Errorf("failed to getRole: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + resume, err := role.RenderResume() + if err != nil { + log.Println(fmt.Errorf("failed to render resume as markdown: %w", err)) + resume = role.Resume + // continuing... + } + + ctx.HTML(200, "viewrole", gin.H{"role": role, "resume": template.HTML(resume)}) +} + +func (ctrl *Controller) EditRole(ctx *gin.Context) { + session := sessions.Default(ctx) + + id := ctx.Param("id") + role, err := data.GetRole(id, ctrl.DB) + if err != nil { + log.Println(fmt.Errorf("failed to getRole: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + token := ctx.Query("token") + tVars := gin.H{"role": role, "token": token} + + fields := []string{"name", "email", "phone", "role", "resume", "linkedin", "website", "github", "complow", "comphigh"} + for _, k := range fields { + f := fmt.Sprintf("%s_err", k) + tVars[f] = session.Flashes(f) + } + + ctx.HTML(200, "editrole", addFlash(ctx, tVars)) +} + +func (ctrl *Controller) UpdateRole(ctx *gin.Context) { + id := ctx.Param("id") + + var newRoleInput data.NewRole + if err := ctx.Bind(&newRoleInput); err != nil { + log.Println(fmt.Errorf("failed to ctx.Bind: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + session := sessions.Default(ctx) + defer func() { + if err := session.Save(); err != nil { + log.Println(fmt.Errorf("failed to session.Save: %w", err)) + } + }() + + if errs := newRoleInput.Validate(true); len(errs) != 0 { + for k, v := range errs { + session.AddFlash(v, fmt.Sprintf("%s_err", k)) + } + + token := ctx.Query("token") + // TODO: somehow preserve previously provided values? + ctx.Redirect(302, fmt.Sprintf("/roles/%s/edit?token=%s", id, token)) + return + } + + role, err := data.GetRole(id, ctrl.DB) + if err != nil { + log.Println(fmt.Errorf("failed to getRole: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + role.Update(newRoleInput) + if _, err = role.Save(ctrl.DB); err != nil { + log.Println(fmt.Errorf("failed to role.save: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + + session.AddFlash("Role updated!") + ctx.Redirect(302, "/") +} + func addFlash(ctx *gin.Context, base gin.H) gin.H { session := sessions.Default(ctx) base["flashes"] = session.Flashes() diff --git a/pkg/server/routes_test.go b/pkg/server/routes_test.go index 3808d52..4ade6c4 100644 --- a/pkg/server/routes_test.go +++ b/pkg/server/routes_test.go @@ -35,10 +35,17 @@ func TestIndex(t *testing.T) { {Position: "Pos 2"}, }) + expectSelectRolesQuery(dbmock, []data.Role{ + {Name: "Foo Bar"}, + {Name: "Baz Qux"}, + }) + body, _ := sendRequest(t, s.URL, nil) assert.Contains(t, string(body), "Pos 1") assert.Contains(t, string(body), "Pos 2") + assert.Contains(t, string(body), "Foo Bar") + assert.Contains(t, string(body), "Baz Qux") // TODO: What other assertions do we want to make about the home page? } @@ -80,6 +87,48 @@ func TestNewJob(t *testing.T) { } } +func TestNewRole(t *testing.T) { + s, _, _, _ := makeServer(t) + defer s.Close() + + body, resp := sendRequest(t, fmt.Sprintf("%s/newrole", s.URL), nil) + + assert.Equal(t, 200, resp.StatusCode) + + // - assert that all the right fields are present + tests := []struct { + field string + required bool + textArea bool + }{ + {"name", true, false}, + {"email", true, false}, + {"phone", false, false}, + {"role", true, false}, + {"resume", true, true}, + {"linkedin", false, false}, + {"website", false, false}, + {"github", false, false}, + {"complow", false, false}, + {"comphigh", false, false}, + } + + for _, tt := range tests { + reqStr := "" + if tt.required { + reqStr = "required.*" + } + + base := "input" + if tt.textArea { + base = "textarea" + } + + r := fmt.Sprintf(`<%s.+name="%s".*%s>`, base, tt.field, reqStr) + assert.Regexp(t, regexp.MustCompile(r), body) + } +} + func TestCreateJob(t *testing.T) { s, svcmock, dbmock, conf := makeServer(t) defer s.Close() @@ -150,6 +199,7 @@ func TestCreateJob(t *testing.T) { ) expectSelectJobsQuery(dbmock, []data.Job{newJob}) + expectSelectRolesQuery(dbmock, []data.Role{}) } reqBody := url.Values(tt.values).Encode() @@ -164,21 +214,138 @@ func TestCreateJob(t *testing.T) { assert.Equal(t, 1, len(svcmock.emails)) assert.Equal(t, 1, len(svcmock.tweets)) - assert.Equal(t, 1, len(svcmock.slacks)) + assert.Equal(t, 1, len(svcmock.jobSlacks)) + assert.Equal(t, 0, len(svcmock.roleSlacks)) assert.Equal(t, "Job Created!", svcmock.emails[0].subject) assert.Equal(t, tt.values["email"][0], svcmock.emails[0].recipient) assert.Contains(t, svcmock.emails[0].body, server.SignedJobRoute(newJob, conf)) assert.Contains(t, svcmock.tweets, newJob) - assert.Contains(t, svcmock.slacks, newJob) + assert.Contains(t, svcmock.jobSlacks, newJob) } else { for _, errMsg := range tt.expectErrMessages { assert.Contains(t, respBody, errMsg) } assert.Empty(t, svcmock.emails) assert.Empty(t, svcmock.tweets) - assert.Empty(t, svcmock.slacks) + assert.Empty(t, svcmock.jobSlacks) + assert.Empty(t, svcmock.roleSlacks) + } + + resetServiceMock(svcmock) + } +} + +func TestCreateRole(t *testing.T) { + s, svcmock, dbmock, conf := makeServer(t) + defer s.Close() + + tests := []struct { + values map[string][]string + expectSuccess bool + expectErrMessages []string + }{ + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"test@example.com"}, + "phone": {"316-555-1111"}, + "role": {"role 1"}, + "resume": {"neat"}, + "linkedin": {"https://www.linkedin.com/example"}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: true, + }, + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"test@example.com"}, + "phone": {""}, + "role": {"role 2"}, + "resume": {"wow"}, + "linkedin": {""}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: true, + }, + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"testexample.com"}, + "phone": {""}, + "role": {"role 1"}, + "resume": {"cool"}, + "linkedin": {""}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: false, + expectErrMessages: []string{data.ErrInvalidEmail}, + }, + } + + for _, tt := range tests { + newRole := data.Role{ + ID: "1", + Name: tt.values["name"][0], + Email: tt.values["email"][0], + Phone: sql.NullString{String: tt.values["phone"][0], Valid: tt.values["phone"][0] != ""}, + Role: tt.values["role"][0], + Resume: tt.values["resume"][0], + Linkedin: sql.NullString{String: tt.values["linkedin"][0], Valid: tt.values["linkedin"][0] != ""}, + Website: sql.NullString{String: tt.values["website"][0], Valid: tt.values["website"][0] != ""}, + Github: sql.NullString{String: tt.values["github"][0], Valid: tt.values["github"][0] != ""}, + CompLow: sql.NullString{String: tt.values["complow"][0], Valid: tt.values["complow"][0] != ""}, + CompHigh: sql.NullString{String: tt.values["comphigh"][0], Valid: tt.values["comphigh"][0] != ""}, + PublishedAt: time.Now(), + } + + if tt.expectSuccess { + dbmock.ExpectQuery(`INSERT INTO roles`).WillReturnRows( + sqlmock.NewRows(getDbFields(data.Role{})).AddRow(mockRoleRow(newRole)...), + ) + + expectSelectJobsQuery(dbmock, []data.Job{}) + expectSelectRolesQuery(dbmock, []data.Role{newRole}) + } + + reqBody := url.Values(tt.values).Encode() + respBody, resp := sendRequest(t, fmt.Sprintf("%s/roles", s.URL), []byte(reqBody)) + + // Should follow the redirect and result in a 200 regardless of success/failure + assert.Equal(t, 200, resp.StatusCode) + + if tt.expectSuccess { + assert.Contains(t, respBody, tt.values["name"][0]) + assert.Contains(t, respBody, tt.values["role"][0]) + + assert.Equal(t, 1, len(svcmock.emails)) + assert.Equal(t, 0, len(svcmock.tweets)) + assert.Equal(t, 0, len(svcmock.jobSlacks)) + assert.Equal(t, 1, len(svcmock.roleSlacks)) + + assert.Equal(t, "Post Created!", svcmock.emails[0].subject) + assert.Equal(t, tt.values["email"][0], svcmock.emails[0].recipient) + assert.Contains(t, svcmock.emails[0].body, server.SignedRoleRoute(newRole, conf)) + assert.Contains(t, svcmock.roleSlacks, newRole) + } else { + for _, errMsg := range tt.expectErrMessages { + assert.Contains(t, respBody, errMsg) + } + assert.Empty(t, svcmock.emails) + assert.Empty(t, svcmock.tweets) + assert.Empty(t, svcmock.jobSlacks) + assert.Empty(t, svcmock.roleSlacks) } resetServiceMock(svcmock) @@ -235,6 +402,64 @@ func TestViewJob(t *testing.T) { } } +func TestViewRole(t *testing.T) { + s, _, dbmock, _ := makeServer(t) + defer s.Close() + + tests := []struct { + role data.Role + }{ + { + role: data.Role{ + ID: "1", + Name: "Test Testly", + Email: "test@example.com", + Phone: sql.NullString{String: "316-555-1515", Valid: true}, + Role: "Super cool role", + Resume: "# hey\n\nhire this guy", + Linkedin: sql.NullString{String: "https://www.linkedin.com/in/example", Valid: true}, + Website: sql.NullString{}, + Github: sql.NullString{}, + CompLow: sql.NullString{}, + CompHigh: sql.NullString{}, + }, + }, + { + role: data.Role{ + ID: "2", + Name: "Not Test Testly", + Email: "test2@example.com", + Phone: sql.NullString{}, + Role: "Also cool role", + Resume: "# too cool\n\nfor linkedin", + Linkedin: sql.NullString{}, + Website: sql.NullString{}, + Github: sql.NullString{}, + CompLow: sql.NullString{}, + CompHigh: sql.NullString{}, + }, + }, + } + + for _, tt := range tests { + expectGetRoleQuery(dbmock, tt.role) + + respBody, resp := sendRequest(t, fmt.Sprintf("%s/roles/%s", s.URL, tt.role.ID), nil) + + assert.Equal(t, 200, resp.StatusCode) + assert.Contains(t, respBody, tt.role.Name) + assert.Contains(t, respBody, tt.role.Role) + + if tt.role.Phone.Valid { + assert.Contains(t, respBody, tt.role.Phone.String) + } + + if tt.role.Linkedin.Valid { + assert.Contains(t, respBody, tt.role.Linkedin.String) + } + } +} + func TestEditJobUnauthorized(t *testing.T) { s, _, dbmock, _ := makeServer(t) @@ -246,6 +471,17 @@ func TestEditJobUnauthorized(t *testing.T) { assert.Equal(t, 403, resp.StatusCode) } +func TestEditRoleUnauthorized(t *testing.T) { + s, _, dbmock, _ := makeServer(t) + + role := data.Role{ID: "1", PublishedAt: time.Now()} + + expectGetRoleQuery(dbmock, role) + + _, resp := sendRequest(t, fmt.Sprintf("%s/roles/%s/edit?token=incorrect", s.URL, role.ID), nil) + assert.Equal(t, 403, resp.StatusCode) +} + func TestUpdateJobUnauthorized(t *testing.T) { s, _, dbmock, _ := makeServer(t) @@ -257,6 +493,17 @@ func TestUpdateJobUnauthorized(t *testing.T) { assert.Equal(t, 403, resp.StatusCode) } +func TestUpdateRoleUnauthorized(t *testing.T) { + s, _, dbmock, _ := makeServer(t) + + role := data.Role{ID: "1", PublishedAt: time.Now()} + + expectGetRoleQuery(dbmock, role) + + _, resp := sendRequest(t, fmt.Sprintf("%s/roles/%s?token=incorrect", s.URL, role.ID), []byte("daaaata")) + assert.Equal(t, 403, resp.StatusCode) +} + func TestUpdateJobDoesNotExist(t *testing.T) { s, _, dbmock, _ := makeServer(t) @@ -268,6 +515,17 @@ func TestUpdateJobDoesNotExist(t *testing.T) { assert.Equal(t, 404, resp.StatusCode) } +func TestUpdateRoleDoesNotExist(t *testing.T) { + s, _, dbmock, _ := makeServer(t) + + role := data.Role{ID: "1", PublishedAt: time.Now()} + + expectGetRoleQuery(dbmock, role) + + _, resp := sendRequest(t, fmt.Sprintf("%s/roles/%s?token=incorrect", s.URL, "32"), []byte("daaaata")) + assert.Equal(t, 404, resp.StatusCode) +} + func TestEditJobAuthorized(t *testing.T) { _, _, dbmock, conf := makeServer(t) @@ -295,6 +553,39 @@ func TestEditJobAuthorized(t *testing.T) { assert.Regexp(t, fmt.Sprintf(`%s`, job.Description.String), respBody) } +func TestEditRoleAuthorized(t *testing.T) { + _, _, dbmock, conf := makeServer(t) + + role := data.Role{ + ID: "1", + Name: "Test", + Email: "test@example.com", + Phone: sql.NullString{String: "316-555-1515", Valid: true}, + Role: "any", + Resume: "# hey \n\n yo", + Linkedin: sql.NullString{}, + Website: sql.NullString{}, + Github: sql.NullString{}, + CompLow: sql.NullString{}, + CompHigh: sql.NullString{}, + PublishedAt: time.Now(), + } + + // Query executes twice, once for the auth middleware, and + // a second time for the actual route + expectGetRoleQuery(dbmock, role) + expectGetRoleQuery(dbmock, role) + + signedEditRoute := server.SignedRoleRoute(role, conf) + respBody, resp := sendRequest(t, signedEditRoute, nil) + + assert.Equal(t, 200, resp.StatusCode) + + assert.Regexp(t, fmt.Sprintf(``, role.Name), respBody) + assert.Regexp(t, fmt.Sprintf(``, role.Role), respBody) + assert.Regexp(t, fmt.Sprintf(`%s`, role.Resume), respBody) +} + func TestUpdateJobAuthorized(t *testing.T) { s, svcmock, dbmock, conf := makeServer(t) defer s.Close() @@ -382,6 +673,7 @@ func TestUpdateJobAuthorized(t *testing.T) { ).WillReturnResult(sqlmock.NewResult(0, 1)) expectSelectJobsQuery(dbmock, []data.Job{newParams}) + expectSelectRolesQuery(dbmock, []data.Role{}) } else { // On failure, expect twice again for the redirect to /edit // which calls requireAuth, and then getJob for the view @@ -404,7 +696,8 @@ func TestUpdateJobAuthorized(t *testing.T) { // Should not resend any notifications on updates assert.Empty(t, svcmock.emails) assert.Empty(t, svcmock.tweets) - assert.Empty(t, svcmock.slacks) + assert.Empty(t, svcmock.jobSlacks) + assert.Empty(t, svcmock.roleSlacks) if tt.expectSuccess { assert.Contains(t, respBody, tt.values["position"][0]) @@ -415,7 +708,151 @@ func TestUpdateJobAuthorized(t *testing.T) { } } } +} + +func TestUpdateRoleAuthorized(t *testing.T) { + s, svcmock, dbmock, conf := makeServer(t) + defer s.Close() + + tests := []struct { + values map[string][]string + expectSuccess bool + expectErrMessages []string + }{ + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"test@example.com"}, + "phone": {"316-555-1111"}, + "role": {"role 1"}, + "resume": {"# Cool\n\nwow"}, + "linkedin": {"https://www.linkedin.com/example"}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: true, + }, + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"test@example.com"}, + "phone": {""}, + "role": {"role 2"}, + "resume": {"# Cool\n\nwow"}, + "linkedin": {""}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: true, + }, + { + values: map[string][]string{ + "name": {"Test McTestington"}, + "email": {"test@example.com"}, + "phone": {""}, + "role": {""}, + "resume": {"# Cool\n\nwow"}, + "linkedin": {""}, + "website": {""}, + "github": {""}, + "complow": {""}, + "comphigh": {""}, + }, + expectSuccess: false, + expectErrMessages: []string{data.ErrNoRole}, + }, + } + + for _, tt := range tests { + role := data.Role{ + ID: "1", + Name: "Original Name", + Email: "test@example.com", + Phone: sql.NullString{Valid: false}, + Role: "Original Role", + Resume: "Original Resume", + Linkedin: sql.NullString{Valid: false}, + Website: sql.NullString{Valid: false}, + Github: sql.NullString{Valid: false}, + CompLow: sql.NullString{Valid: false}, + CompHigh: sql.NullString{Valid: false}, + PublishedAt: time.Now(), + } + // Expect requireAuth query + expectGetRoleQuery(dbmock, role) + + newParams := data.Role{ + ID: role.ID, + Name: tt.values["name"][0], + Email: role.Email, + Phone: sql.NullString{String: tt.values["phone"][0], Valid: tt.values["phone"][0] != ""}, + Role: tt.values["role"][0], + Resume: tt.values["resume"][0], + Linkedin: sql.NullString{String: tt.values["linkedin"][0], Valid: tt.values["linkedin"][0] != ""}, + Website: sql.NullString{String: tt.values["website"][0], Valid: tt.values["website"][0] != ""}, + Github: sql.NullString{String: tt.values["github"][0], Valid: tt.values["github"][0] != ""}, + CompLow: sql.NullString{String: tt.values["complow"][0], Valid: tt.values["complow"][0] != ""}, + CompHigh: sql.NullString{String: tt.values["comphigh"][0], Valid: tt.values["comphigh"][0] != ""}, + PublishedAt: role.PublishedAt, + } + + if tt.expectSuccess { + expectGetRoleQuery(dbmock, role) + + dbmock.ExpectExec(`UPDATE roles .+ WHERE id = .+`).WithArgs( + tt.values["name"][0], + sql.NullString{String: tt.values["phone"][0], Valid: tt.values["phone"][0] != ""}, + tt.values["role"][0], + tt.values["resume"][0], + sql.NullString{String: tt.values["linkedin"][0], Valid: tt.values["linkedin"][0] != ""}, + sql.NullString{String: tt.values["website"][0], Valid: tt.values["website"][0] != ""}, + sql.NullString{String: tt.values["github"][0], Valid: tt.values["github"][0] != ""}, + sql.NullString{String: tt.values["complow"][0], Valid: tt.values["complow"][0] != ""}, + sql.NullString{String: tt.values["comphigh"][0], Valid: tt.values["comphigh"][0] != ""}, + role.ID, + ).WillReturnResult(sqlmock.NewResult(0, 1)) + + expectSelectJobsQuery(dbmock, []data.Job{}) + expectSelectRolesQuery(dbmock, []data.Role{newParams}) + } else { + // On failure, expect twice again for the redirect to /edit + // which calls requireAuth, and then getJob for the view + expectGetRoleQuery(dbmock, role) + expectGetRoleQuery(dbmock, role) + } + + reqBody := url.Values(tt.values).Encode() + route := fmt.Sprintf( + "%s/roles/%s?token=%s", + s.URL, + role.ID, + role.AuthSignature(conf.AppSecret), + ) + respBody, resp := sendRequest(t, route, []byte(reqBody)) + + // Should follow the redirect and result in a 200 regardless of success/failure + assert.Equal(t, 200, resp.StatusCode) + + // Should not resend any notifications on updates + assert.Empty(t, svcmock.emails) + assert.Empty(t, svcmock.tweets) + assert.Empty(t, svcmock.jobSlacks) + assert.Empty(t, svcmock.roleSlacks) + + if tt.expectSuccess { + assert.Contains(t, respBody, tt.values["name"][0]) + assert.Contains(t, respBody, tt.values["role"][0]) + } else { + for _, errMsg := range tt.expectErrMessages { + assert.Contains(t, respBody, errMsg) + } + } + } } // Helpers ------------------------------ @@ -425,9 +862,10 @@ type email struct { } type mockService struct { - emails []email - tweets []data.Job - slacks []data.Job + emails []email + tweets []data.Job + jobSlacks []data.Job + roleSlacks []data.Role } func (svc *mockService) SendEmail(recipient, subject, body string) error { @@ -440,8 +878,13 @@ func (svc *mockService) PostToTwitter(job data.Job) error { return nil } -func (svc *mockService) PostToSlack(job data.Job) error { - svc.slacks = append(svc.slacks, job) +func (svc *mockService) PostJobToSlack(job data.Job) error { + svc.jobSlacks = append(svc.jobSlacks, job) + return nil +} + +func (svc *mockService) PostRoleToSlack(role data.Role) error { + svc.roleSlacks = append(svc.roleSlacks, role) return nil } @@ -500,7 +943,8 @@ func sendRequest(t *testing.T, path string, postBody []byte) (string, *http.Resp func resetServiceMock(svc *mockService) { svc.emails = []email{} svc.tweets = []data.Job{} - svc.slacks = []data.Job{} + svc.jobSlacks = []data.Job{} + svc.roleSlacks = []data.Role{} } func getDbFields(thing interface{}) []string { @@ -560,6 +1004,73 @@ func mockJobRow(job data.Job) []driver.Value { return vals } +func mockRoleRow(role data.Role) []driver.Value { + vals := []driver.Value{ + "1", + "Test Testington", + "example@example.com", + sql.NullString{}, + "All roles", + "wow cool", + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + sql.NullString{}, + time.Now(), + } + + if role.ID != "" { + vals[0] = role.ID + } + + if role.Name != "" { + vals[1] = role.Name + } + + if role.Email != "" { + vals[2] = role.Email + } + + if role.Phone.Valid { + vals[3] = role.Phone + } + + if role.Role != "" { + vals[4] = role.Role + } + + if role.Resume != "" { + vals[5] = role.Resume + } + + if role.Linkedin.Valid { + vals[6] = role.Linkedin + } + + if role.Website.Valid { + vals[7] = role.Website + } + + if role.Github.Valid { + vals[8] = role.Github + } + + if role.CompLow.Valid { + vals[9] = role.CompLow + } + + if role.CompHigh.Valid { + vals[10] = role.CompHigh + } + + if !role.PublishedAt.IsZero() { + vals[11] = role.PublishedAt + } + + return vals +} + func expectSelectJobsQuery(dbmock sqlmock.Sqlmock, jobs []data.Job) { rows := sqlmock.NewRows(getDbFields(data.Job{})) for _, job := range jobs { @@ -568,9 +1079,23 @@ func expectSelectJobsQuery(dbmock sqlmock.Sqlmock, jobs []data.Job) { dbmock.ExpectQuery(`SELECT \* FROM jobs`).WillReturnRows(rows) } +func expectSelectRolesQuery(dbmock sqlmock.Sqlmock, roles []data.Role) { + rows := sqlmock.NewRows(getDbFields(data.Role{})) + for _, role := range roles { + rows.AddRow(mockRoleRow(role)...) + } + dbmock.ExpectQuery(`SELECT \* FROM roles`).WillReturnRows(rows) +} + // TODO: use this everywhere func expectGetJobQuery(dbmock sqlmock.Sqlmock, job data.Job) { dbmock.ExpectQuery(`SELECT \* FROM jobs.+`).WillReturnRows( sqlmock.NewRows(getDbFields(data.Job{})).AddRow(mockJobRow(job)...), ) } + +func expectGetRoleQuery(dbmock sqlmock.Sqlmock, role data.Role) { + dbmock.ExpectQuery(`SELECT \* FROM roles.+`).WillReturnRows( + sqlmock.NewRows(getDbFields(data.Role{})).AddRow(mockRoleRow(role)...), + ) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 334b1a7..cc93b35 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -20,6 +20,7 @@ import ( ) const JobRoute = "job" +const RoleRoute = "role" type ServerConfig struct { Config *config.Config @@ -69,12 +70,22 @@ func NewServer(c *ServerConfig) (http.Server, error) { router.GET("/new", ctrl.NewJob) router.POST("/jobs", ctrl.CreateJob) router.GET("/jobs/:id", ctrl.ViewJob) + router.GET("/newrole", ctrl.NewRole) + router.POST("/roles", ctrl.CreateRole) + router.GET("/roles/:id", ctrl.ViewRole) - authorized := router.Group("/") - authorized.Use(requireTokenAuth(sqlxDb, c.Config.AppSecret, JobRoute)) + authorizedJobs := router.Group("/jobs") + authorizedJobs.Use(requireTokenAuth(sqlxDb, c.Config.AppSecret, JobRoute)) { - authorized.GET("/jobs/:id/edit", ctrl.EditJob) - authorized.POST("/jobs/:id", ctrl.UpdateJob) + authorizedJobs.GET(":id/edit", ctrl.EditJob) + authorizedJobs.POST(":id", ctrl.UpdateJob) + } + + authorizedRoles := router.Group("/roles") + authorizedRoles.Use(requireTokenAuth(sqlxDb, c.Config.AppSecret, RoleRoute)) + { + authorizedRoles.GET(":id/edit", ctrl.EditRole) + authorizedRoles.POST(":id", ctrl.UpdateRole) } return http.Server{ @@ -97,6 +108,9 @@ func renderer(templatePath string) multitemplate.Renderer { r.AddFromFilesFuncs("new", funcMap, basePath, path.Join(templatePath, "new.html")) r.AddFromFilesFuncs("edit", funcMap, basePath, path.Join(templatePath, "edit.html")) r.AddFromFilesFuncs("view", funcMap, basePath, path.Join(templatePath, "view.html")) + r.AddFromFilesFuncs("newrole", funcMap, basePath, path.Join(templatePath, "newrole.html")) + r.AddFromFilesFuncs("editrole", funcMap, basePath, path.Join(templatePath, "editrole.html")) + r.AddFromFilesFuncs("viewrole", funcMap, basePath, path.Join(templatePath, "viewrole.html")) return r } @@ -120,6 +134,20 @@ func requireTokenAuth(db *sqlx.DB, secret, authType string) func(*gin.Context) { return } expected = []byte(job.AuthSignature(secret)) + case RoleRoute: + roleID := ctx.Param("id") + role, err := data.GetRole(roleID, db) + if err != nil { + log.Println(fmt.Errorf("requiretokenauth failed to getRole: %w", err)) + ctx.AbortWithStatus(http.StatusInternalServerError) + return + } + if role.ID != roleID { + log.Println(fmt.Errorf("requiretokenauth failed to find role with getRole: %w", err)) + ctx.AbortWithStatus(http.StatusNotFound) + return + } + expected = []byte(role.AuthSignature(secret)) default: log.Println("requireTokenAuth failed, unexpected authType:", authType) ctx.AbortWithStatus(http.StatusInternalServerError) diff --git a/pkg/server/signed.go b/pkg/server/signed.go index b30cdea..056fbff 100644 --- a/pkg/server/signed.go +++ b/pkg/server/signed.go @@ -16,3 +16,12 @@ func SignedJobRoute(job data.Job, c *config.Config) string { url.QueryEscape(job.AuthSignature(c.AppSecret)), ) } + +func SignedRoleRoute(role data.Role, c *config.Config) string { + return fmt.Sprintf( + "%s/roles/%s/edit?token=%s", + c.URL, + role.ID, + url.QueryEscape(role.AuthSignature(c.AppSecret)), + ) +} diff --git a/pkg/services/slack.go b/pkg/services/slack.go index 05ae234..b7a05b1 100644 --- a/pkg/services/slack.go +++ b/pkg/services/slack.go @@ -11,7 +11,8 @@ import ( ) type ISlackService interface { - PostToSlack(data.Job) error + PostJobToSlack(data.Job) error + PostRoleToSlack(data.Role) error } type SlackService struct { @@ -22,7 +23,7 @@ type SlackMessage struct { Text string `json:"text"` } -func (svc *SlackService) PostToSlack(job data.Job) error { +func (svc *SlackService) PostJobToSlack(job data.Job) error { message := slackMessageFromJob(job, svc.Conf) messageStr, err := json.Marshal(message) if err != nil { @@ -47,3 +48,29 @@ func slackMessageFromJob(job data.Job, c *config.Config) SlackMessage { ) return SlackMessage{Text: text} } + +func (svc *SlackService) PostRoleToSlack(role data.Role) error { + message := slackMessageFromRole(role, svc.Conf) + messageStr, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("failed to marshal slack message: %w", err) + } + + _, err = http.Post(svc.Conf.SlackHook, "application/json", bytes.NewReader(messageStr)) + if err != nil { + return fmt.Errorf("failed to post to slack: %w", err) + } + + return nil +} + +func slackMessageFromRole(role data.Role, c *config.Config) SlackMessage { + text := fmt.Sprintf( + "A new resume was posted!\n> *<%s/roles/%s|%s @ %s>*", + c.URL, + role.ID, + role.Name, + role.Role, + ) + return SlackMessage{Text: text} +} diff --git a/sql/20230111231300_create_roles.down.sql b/sql/20230111231300_create_roles.down.sql new file mode 100644 index 0000000..06e938c --- /dev/null +++ b/sql/20230111231300_create_roles.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS roles; diff --git a/sql/20230111231300_create_roles.up.sql b/sql/20230111231300_create_roles.up.sql new file mode 100644 index 0000000..d7b38df --- /dev/null +++ b/sql/20230111231300_create_roles.up.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + email TEXT NOT NULL, + phone TEXT NULL, + role TEXT NOT NULL, + resume TEXT NOT NULL, + linkedin TEXT NULL, + website TEXT NULL, + github TEXT NULL, + comp_low TEXT NULL, + comp_high TEXT NULL, + published_at TIMESTAMP DEFAULT current_timestamp +); \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index 9d91400..824d3eb 100644 --- a/templates/base.html +++ b/templates/base.html @@ -26,7 +26,14 @@ Post a job + + + + + Looking for work? + +

Questions?

diff --git a/templates/editrole.html b/templates/editrole.html new file mode 100644 index 0000000..0d18ebb --- /dev/null +++ b/templates/editrole.html @@ -0,0 +1,88 @@ +{{ define "content" }} +
+ + + + + + + + + + +
+{{ end }} diff --git a/templates/index.html b/templates/index.html index 978807f..0e63c85 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,4 +1,5 @@ {{ define "content" }} +

Jobs

    {{ range .jobs }}
  • @@ -36,4 +37,29 @@

    {{ .Position }}

  • {{ end }}
+

Job Seekers

+ {{ end }} diff --git a/templates/newrole.html b/templates/newrole.html new file mode 100644 index 0000000..a406d0f --- /dev/null +++ b/templates/newrole.html @@ -0,0 +1,98 @@ +{{ define "content" }} +
+ + + + + + + + + + + +
+{{ end }} diff --git a/templates/viewrole.html b/templates/viewrole.html new file mode 100644 index 0000000..092886c --- /dev/null +++ b/templates/viewrole.html @@ -0,0 +1,40 @@ +{{ define "content" }} +

{{ .role.Name }}{{ if .role.Phone.Valid }} ({{ .role.Phone.String }}){{ end }}

+
{{ .role.Role }}
+ {{ if .role.CompLow.Valid }} +
Seeking Compensation From: {{ .role.CompLow.String }}
+ {{ end }} + {{ if .role.CompHigh.Valid }} +
Seeking Compensation Up To: {{ .role.CompHigh.String }}
+ {{ end }} +
+
{{ .resume }}
+
+ + Email + + {{ if .role.Linkedin.Valid }} + + LinkedIn + + {{ end }} + {{ if .role.Website.Valid }} + + Website + + {{ end }} + {{ if .role.Github.Valid }} + + GitHub + + {{ end }} +
+ + + +{{ end }}